diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0738400 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,164 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + # release.yml runs this whole workflow as its gate. + workflow_call: + # Nightly, so drift between this gem and the framework it extends surfaces + # without anyone having to push. + schedule: + - cron: "0 7 * * *" + +jobs: + test: + name: Ruby ${{ matrix.ruby }} + runs-on: ubuntu-latest + env: + BUNDLE_JOBS: 4 + BUNDLE_RETRY: 3 + CI: true + strategy: + fail-fast: false + matrix: + # The gemspec claims >= 3.0, but Gemfile.lock's `BUNDLED WITH 2.7.2` + # cannot install below 3.2 — so 3.2 is the floor this can actually + # prove. Either raise required_ruby_version to match, or pin an + # older bundler and add the earlier versions back. + ruby: [ "3.2", "3.3", "3.4" ] + steps: + - uses: actions/checkout@v6 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run tests + run: bundle exec rake test + + # This gem's own suite runs against mocks — deliberately, so it stays fast + # and dependency-free — which means it can pass while the concerns no + # longer compose with the framework they extend. activeagent carries a + # dummy Rails app and a cross-repo suite for exactly that; run it here + # against this working tree. + # + # SOLID_AGENT_STRICT=1: a test that would skip because the resolved gem + # lacks an API fails instead. Here the resolved gem *is* this checkout, so + # a skip would mean something was removed. + integration: + name: activeagent (${{ matrix.activeagent_ref }}) + runs-on: ubuntu-latest + env: + BUNDLE_JOBS: 4 + BUNDLE_RETRY: 3 + CI: true + RAILS_ENV: test + SOLID_AGENT_STRICT: "1" + # Placeholders, matching activeagent's own CI: its config/active_agent.yml + # reads these at boot and provider clients raise when constructed without + # one. Nothing here reaches a provider — every generation in the + # cross-repo suite goes through the mock provider — so the values are + # deliberately not secrets, and a real call would fail loudly rather than + # bill anyone. + 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: + # main catches breakage before the framework releases; the latest + # tag catches breakage against what users have installed. + activeagent_ref: [ main, latest-release ] + steps: + - uses: actions/checkout@v6 + with: + path: solid_agent + + - name: Check out activeagent + uses: actions/checkout@v6 + with: + repository: activeagents/activeagent + path: activeagent + fetch-depth: 0 + + - name: Select the activeagent revision + working-directory: activeagent + run: | + if [ "${{ matrix.activeagent_ref }}" = "latest-release" ]; then + tag=$(git tag --list 'v*' --sort=-v:refname | head -n1) + if [ -z "$tag" ]; then + echo "No release tag found; staying on main." >> "$GITHUB_STEP_SUMMARY" + else + echo "Testing against activeagent $tag" >> "$GITHUB_STEP_SUMMARY" + git checkout --detach "$tag" + fi + fi + + # The harness lives in activeagent, so revisions predating it have + # nothing to run — every release tag until one ships with it, and + # main until activeagents/activeagent#364 merges. That is a skip with + # a reason, not a failure: this job exists to report on the pairing, + # and "the other side is too old" is a legitimate report. + - name: Check the cross-repo harness is present + id: harness + run: | + if [ -f activeagent/gemfiles/solid_agent_main.gemfile ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "activeagent (${{ matrix.activeagent_ref }}) has no gemfiles/solid_agent_main.gemfile — it predates the cross-repo suite, so there is nothing to run against it." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Install system deps + if: steps.harness.outputs.present == 'true' + 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 + if: steps.harness.outputs.present == 'true' + with: + ruby-version: "3.4" + bundler-cache: true + working-directory: activeagent + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/activeagent/gemfiles/solid_agent_main.gemfile + SOLID_AGENT_PATH: ${{ github.workspace }}/solid_agent + + - name: Setup database + if: steps.harness.outputs.present == 'true' + working-directory: activeagent/test/dummy + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/activeagent/gemfiles/solid_agent_main.gemfile + SOLID_AGENT_PATH: ${{ github.workspace }}/solid_agent + run: | + bundle exec ruby bin/rails db:create + bundle exec ruby bin/rails db:migrate + + # Includes the dashboard engine's execution test: it builds an agent + # class around SolidAgent::HasContext, and is the path a keyword rename + # in this gem breaks first. + - name: Run the cross-repo suite + if: steps.harness.outputs.present == 'true' + working-directory: activeagent + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/activeagent/gemfiles/solid_agent_main.gemfile + SOLID_AGENT_PATH: ${{ github.workspace }}/solid_agent + run: | + bin/test test/integration/solid_agent/*_test.rb \ + actionagent/test/agent_execution_service_test.rb + + - name: Report the resolved versions + if: always() && steps.harness.outputs.present == 'true' + working-directory: activeagent + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/activeagent/gemfiles/solid_agent_main.gemfile + SOLID_AGENT_PATH: ${{ github.workspace }}/solid_agent + run: | + { + echo '```' + bundle list | grep -E "activeagent|actionagent|solid_agent" || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6e8c46f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: Release + +# Builds the gem, attaches it to a GitHub release, and publishes to RubyGems +# via trusted publishing (OIDC — no stored API key). +# +# Requires a trusted publisher configured on rubygems.org for solid_agent, +# pointing at this repository and this workflow file (release.yml). Until +# that exists, the push step fails with an authorization error. +# +# Mirrors activeagents/activeagent's release workflow, including the gate: +# this gem depends on activeagent and the dashboard engine depends on this +# gem, so a release that the cross-repo suite has not seen is a release that +# can break either neighbour. + +on: + push: + tags: [ "v*" ] + workflow_dispatch: + +jobs: + # The full CI suite, including the cross-repo job that runs this working + # tree against activeagent main and against its latest release. + verify: + uses: ./.github/workflows/ci.yml + secrets: inherit + + build: + needs: verify + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + + # A gem that installs and resolves but dies on require is the failure + # this guards against, and it is invisible without looking inside the + # archive. + - name: Build and inspect the gem + run: | + gem build solid_agent.gemspec + mkdir -p pkg && mv solid_agent-*.gem pkg/ + contents=$(tar -xOf pkg/solid_agent-*.gem data.tar.gz | tar -tzf -) + for path in lib/solid_agent.rb lib/solid_agent/has_context.rb; do + echo "$contents" | grep -qx "$path" || { echo "gem is missing $path"; exit 1; } + done + + - uses: actions/upload-artifact@v4 + with: + name: gem + path: pkg/*.gem + + - name: Attach the gem to the release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: pkg/*.gem + generate_release_notes: true + + publish: + # Tag pushes release; workflow_dispatch covers environments where tags + # can't be pushed directly. RubyGems refuses to overwrite an existing + # version, so an accidental re-run cannot double-publish. + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + permissions: + id-token: write # OIDC exchange with rubygems.org + contents: read + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + + # The action publishes no v1 tag; @main is the ref its docs pin. + - uses: rubygems/configure-rubygems-credentials@main + + - name: Publish to RubyGems + run: | + gem build solid_agent.gemspec + gem_path=$(ls solid_agent-*.gem) + version=$(basename "$gem_path" .gem | sed -E 's/^solid_agent-//') + + # `gem list --all` prints one line: `name (1.2.0, 1.1.0, ...)`, so + # the version is matched between its delimiters — anchoring on + # "($version" alone would only ever see the newest release. + if gem list --remote --exact --all solid_agent | tr -d ' ' | + grep -qE "[(,]${version//./\\.}[,)]"; then + echo "solid_agent $version is already on RubyGems; nothing to do." + exit 0 + fi + + gem push "$gem_path" diff --git a/Gemfile.lock b/Gemfile.lock index 0cb611b..670adb6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,7 @@ PATH remote: . specs: solid_agent (0.2.0) - activeagent (>= 1.1.0) + activeagent (>= 1.0.0) activemodel (>= 7.0) activerecord (>= 7.0) activesupport (>= 7.0) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b063fb6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Active Agents AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c585c4a..40b28b5 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,20 @@ # SolidAgent +[![Gem Version](https://img.shields.io/gem/v/solid_agent?logo=rubygems&color=CC342D)](https://rubygems.org/gems/solid_agent) +[![Downloads](https://img.shields.io/gem/dt/solid_agent?label=downloads)](https://rubygems.org/gems/solid_agent) +[![CI](https://github.com/activeagents/solid_agent/actions/workflows/ci.yml/badge.svg)](https://github.com/activeagents/solid_agent/actions/workflows/ci.yml) +[![Docs](https://img.shields.io/badge/docs-docs.activeagents.ai%2Fsolid__agent-2563eb)](https://docs.activeagents.ai/solid_agent) +[![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0-CC342D)](https://www.ruby-lang.org) +[![ActiveAgent](https://img.shields.io/badge/activeagent-%3E%3D%201.0-D30001)](https://github.com/activeagents/activeagent) +[![License](https://img.shields.io/github/license/activeagents/solid_agent)](LICENSE) + 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. +**[Documentation](https://docs.activeagents.ai/solid_agent)** · +**[Examples](examples)** · +**[`.agent.md` spec](docs/agent-md-spec.md)** + ## Features Agent-side concerns: @@ -58,12 +70,14 @@ Add database-backed context management to your agents: class WritingAssistantAgent < ApplicationAgent include SolidAgent::HasContext - has_context :conversation, contextual: :user + has_context :conversation, class_name: "AgentContext", contextual: :user def improve - load_conversation(contextable: current_user) # contextable is the polymorphic association - add_conversation_user_message(params[:message]) - prompt messages: conversation_messages + load_conversation(contextable: params[:user]) # contextable is the polymorphic association + + prompt messages: conversation_messages + [ + { role: "user", content: params[:message] } + ] end end ``` @@ -75,6 +89,19 @@ This generates helper methods like: - `add_conversation_assistant_message(content)` - Add an AI response - `conversation_result` - Get the last assistant message +With `auto_save` on (the default), the last prompt message is persisted as +the user turn and the response as the assistant turn, both after the +provider call — so reach for `add_conversation_user_message` only with +`auto_save: false`, or the turn is stored twice. + +> **Naming a context also names its models.** `has_context :conversation` +> infers `Conversation`, `ConversationMessage` and `ConversationGeneration`, +> not the `AgentContext` family the installer wrote — hence `class_name:` +> above, which infers `AgentMessage` and `AgentGeneration` alongside it. +> Unnamed `has_context` resolves to those models directly; for genuinely +> separate tables per context, run +> `rails generate solid_agent:context conversation`. + > **Note:** contexts are persisted under `self.class.name` — agents built > with anonymous `Class.new(...)` must define a class name or context > creation will fail the `agent_name` presence validation. @@ -204,7 +231,10 @@ $ rails generate solid_agent:install # Generate a new agent $ rails generate solid_agent:agent MyAgent -# Generate with context support +# Generate with context support. --context_name emits +# `has_context :session`, which resolves Session/SessionMessage/ +# SessionGeneration — pair it with the context generator below, or drop the +# option to use the installed AgentContext models. $ rails generate solid_agent:agent MyAgent --context --context_name session # Generate a tool template @@ -220,6 +250,24 @@ $ rails generate solid_agent:reasons AgentGeneration $ rails generate solid_agent:manifest research ``` +## Examples + +The [`examples/`](examples) directory has a worked example per concern — +agent classes, views, controllers and console walkthroughs laid out the way +they'd sit in a Rails app: + +| Example | Concerns | +|---------|----------| +| [persistent_conversation](examples/persistent_conversation) | `HasContext` | +| [memory_handoff](examples/memory_handoff) | `HasMemory` | +| [tool_streaming](examples/tool_streaming) | `HasTools`, `StreamsToolUpdates`, `ToolCache` | +| [reasoning](examples/reasoning) | `HasReasons`, `Reasonable` | +| [run_tracking](examples/run_tracking) | `AgentRun`, `RunFingerprint`, `ModelPricing` | +| [manifests](examples/manifests) | `AgentManifest` | + +The narrated versions live at +[docs.activeagents.ai/solid_agent](https://docs.activeagents.ai/solid_agent). + ## Example Apps See SolidAgent in action: @@ -231,7 +279,42 @@ See SolidAgent in action: After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). +```bash +bundle exec rake test +``` + +### Testing against ActiveAgent + +This suite runs against mocks — deliberately, so it stays fast and +dependency-free — which means it can pass while these concerns no longer +compose with the framework they extend. ActiveAgent carries a dummy Rails +app and a cross-repo suite for exactly that. Point it at your working tree: + +```bash +git clone https://github.com/activeagents/activeagent ../activeagent +cd ../activeagent + +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 +``` + +`SOLID_AGENT_STRICT=1` fails on anything the suite would otherwise skip for +a missing API — here the resolved gem *is* your checkout, so a skip means +something was removed. CI runs this on every pull request against +ActiveAgent's main branch and its latest release, and again nightly. See +[Releasing & Cross-Repo Testing](https://docs.activeagents.ai/contributing/releasing). + +### Releasing + +Releases publish from a `v*` tag through +[.github/workflows/release.yml](.github/workflows/release.yml) using RubyGems +trusted publishing, gated on CI including the cross-repo suite. Bump +`SolidAgent::VERSION`, tag, push. This gem depends on `activeagent` and +`actionagent` depends on this gem, so anything requiring a new framework API +waits for that release to land on RubyGems first. ## Contributing diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5592671 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,60 @@ +# SolidAgent Examples + +Runnable, copy-pasteable examples for every SolidAgent concern. Each +directory mirrors the layout of a Rails app, so the files can be dropped +into `app/` as-is and the paths tell you where they belong. + +Every example assumes the persistence tables and models are installed: + +```bash +bundle add solid_agent +rails generate solid_agent:install +rails db:migrate +``` + +That generates `AgentContext`, `AgentMessage`, `AgentGeneration`, +`AgentMemory`, `AgentMemoryEntry` and `AgentRun` into `app/models/`, so they +are yours to edit — SolidAgent's concerns talk to them through a duck-typed +contract, not through hard-coded class names. + +The narrative walkthrough of these examples lives at +[docs.activeagents.ai/solid_agent/examples](https://docs.activeagents.ai/solid_agent/examples). + +| Example | Concerns | What it shows | +|---------|----------|---------------| +| [persistent_conversation](persistent_conversation) | `HasContext` | A support agent whose conversation survives the request, replayed from the database on every turn | +| [memory_handoff](memory_handoff) | `HasMemory` | Two agents sharing agent-curated notes about the same subject record | +| [tool_streaming](tool_streaming) | `HasTools`, `StreamsToolUpdates`, `ToolCache` | Declarative tool schemas, live "what is it doing" updates over ActionCable, cached tool results | +| [reasoning](reasoning) | `HasReasons`, `Reasonable` | Capturing extended-thinking output and persisting it on generation records | +| [run_tracking](run_tracking) | `AgentRun`, `RunFingerprint`, `ModelPricing` | Durable run records, an append-only progress stream a UI can poll, cohorts and cost | +| [manifests](manifests) | `AgentManifest` | Defining an agent in a portable `.agent.md` file and loading it as a class | + +## Running the examples + +The `usage.rb` file in each directory is the console script — the part you +would paste into `rails console` (or call from a controller/job) once the +agent files are in place. They are written to be read top to bottom rather +than executed blind: they hit a provider and write rows. + +Prefer to try one without spending tokens? Point the agent at the mock +provider first: + +```ruby +class SupportAgent < ApplicationAgent + generate_with :mock, model: "mock-gpt-4o-mini" +end +``` + +Persistence, memory, runs and the tool cache all behave identically — only +the model response changes. + +## Related documentation + +- [SolidAgent overview](https://docs.activeagents.ai/solid_agent) +- [Conversation context](https://docs.activeagents.ai/solid_agent/context) +- [Long-term memory](https://docs.activeagents.ai/solid_agent/memory) +- [Tools, streaming and caching](https://docs.activeagents.ai/solid_agent/tools) +- [Reasoning](https://docs.activeagents.ai/solid_agent/reasoning) +- [Runs, cohorts and cost](https://docs.activeagents.ai/solid_agent/runs) +- [Agent manifests](https://docs.activeagents.ai/solid_agent/manifests) +- [`.agent.md` specification](../docs/agent-md-spec.md) diff --git a/examples/manifests/changelog_writer.agent.md b/examples/manifests/changelog_writer.agent.md new file mode 100644 index 0000000..473fc5a --- /dev/null +++ b/examples/manifests/changelog_writer.agent.md @@ -0,0 +1,81 @@ +--- +name: changelog-writer +version: 1.0.0 +description: Turns a range of merged pull requests into a release changelog +author: activeagents +license: MIT +tags: + - writing + - release + +model: anthropic/claude-sonnet-4-20250514 +config: + temperature: 0.3 + max_tokens: 2048 + +input: + schema: + repository: "string, The repository the release belongs to" + from?: "string, Git ref the release starts at" + to?: "string, Git ref the release ends at" + audience?: "string(users, operators, contributors), Who the changelog is written for" + +output: + format: json + schema: + type: object + properties: + headline: + type: string + entries: + type: array + items: + type: object + properties: + title: + type: string + category: + type: string + breaking: + type: array + items: + type: string + +tools: + - name: list_merged_pulls + description: List pull requests merged between two refs + inputSchema: + type: object + properties: + repository: + type: string + from: + type: string + to: + type: string + required: + - repository + +activeagent: + class_name: ChangelogWriterAgent + concerns: + - has_tools: [list_merged_pulls] +--- + +# Changelog Writer + +You write release changelogs from merged pull requests. + +## Instructions + +1. Call `list_merged_pulls` for the requested range. +2. Group the changes into Added, Changed, Fixed and Removed. +3. Write one line per change, in the present tense, describing what a + reader can now do differently — not which files moved. +4. List anything that breaks an existing setup under `breaking`, with the + migration step spelled out. +5. Leave a category out entirely rather than padding it. + +## Template + +Write the changelog for {{ repository }} covering {{ from | default: "the last release" }} to {{ to | default: "HEAD" }}, for an audience of {{ audience | default: "users" }}. diff --git a/examples/manifests/usage.rb b/examples/manifests/usage.rb new file mode 100644 index 0000000..c7c4d21 --- /dev/null +++ b/examples/manifests/usage.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +# Portable agent manifests — rails console walkthrough. +# +# A manifest is an agent definition that lives in a file instead of a class: +# frontmatter for the model, tools, input/output schemas and framework +# extensions, Markdown for the instructions. It can be reviewed in a pull +# request, shipped to another framework, or loaded into a running app. +# +# Docs: https://docs.activeagents.ai/solid_agent/manifests +# Format spec: https://github.com/activeagents/solid_agent/blob/main/docs/agent-md-spec.md + +path = "examples/manifests/changelog_writer.agent.md" + +# --- Read --------------------------------------------------------------- + +manifest = SolidAgent::AgentManifest.parse(path) +manifest.name # => "changelog-writer" +manifest.model # => "anthropic/claude-sonnet-4-20250514" +manifest.tools.map(&:name) +manifest.instructions # the Markdown body +manifest.fingerprint # stable digest — the version an agent ran under + +# `load` takes anything: a path, a URL, a JSON/YAML string, or a Hash. +SolidAgent::AgentManifest.load("https://example.com/agents/support.agent.md") +SolidAgent::AgentManifest.load({ name: "quick", model: "openai/gpt-4o-mini" }) + +# --- Validate ----------------------------------------------------------- + +SolidAgent::AgentManifest.validate(path) # => [] when valid +SolidAgent::AgentManifest.valid?(path) # => true +SolidAgent::AgentManifest.validate(path, strict: true) +SolidAgent::AgentManifest.validate!(path) # raises ValidationError + +# Worth wiring into CI, so a broken manifest fails the build rather than a +# request: +Dir["config/agents/**/*.agent.md"].flat_map { |f| SolidAgent::AgentManifest.validate(f) } + +# --- Build -------------------------------------------------------------- + +# Build an agent class from the manifest. The class arrives configured but +# not finished: it inherits from ApplicationAgent, includes the concerns +# the manifest asked for, carries the manifest's tool schemas, and keeps +# the model, provider and instructions as class attributes. +klass = SolidAgent::AgentManifest.load_agent(path, class_name: "ChangelogWriterAgent") + +klass._manifest_provider # => "anthropic" +klass._manifest_model # => "claude-sonnet-4-20250514" +klass._manifest_instructions # the Markdown body +klass._manifest # the Manifest itself, fingerprint included +klass.new.tools.map { |t| t[:name] } # => ["list_merged_pulls"] + +# `activeagent.class_name` in the frontmatter names the constant, so +# passing class_name: here is only needed to override it. Name it either +# way when persisting context — contexts are keyed by class name, and an +# anonymous class has none. + +# What the manifest does not carry is behaviour: actions and tool bodies +# are still Ruby. Reopen the class and supply them. +# +# class ChangelogWriterAgent +# generate_with _manifest_provider.to_sym, model: _manifest_model +# +# def write +# prompt instructions: _manifest_instructions, +# message: params[:message], +# tools: tools +# end +# +# # Declared tools raise NotImplementedError until you define them. +# def list_merged_pulls(repository:, from: nil, to: nil) +# GitHub.merged_pulls(repository, from: from, to: to) +# end +# end +# +# ChangelogWriterAgent.with(message: "Release 1.2.0").write.generate_now + +# --- Convert ------------------------------------------------------------ + +SolidAgent::AgentManifest.parser_formats # what can be read +SolidAgent::AgentManifest.exporter_formats # what can be written + +# Import someone else's definition... +SolidAgent::AgentManifest.parse("agents.yaml") # CrewAI +SolidAgent::AgentManifest.parse("basic.prompt") # Google Dotprompt +SolidAgent::AgentManifest.parse("copilot.prompt.md") # GitHub Copilot + +# ...and export yours for them. +SolidAgent::AgentManifest.export(manifest, :dotprompt) +SolidAgent::AgentManifest.convert(path, :crewai, "tmp/agents.yaml") + +# --- Provenance --------------------------------------------------------- + +# What an agent ran under, checksummed — pairs with the provenance +# HasContext records on every generation. +SolidAgent::AgentManifest.provenance(manifest) diff --git a/examples/memory_handoff/app/agents/researcher_agent.rb b/examples/memory_handoff/app/agents/researcher_agent.rb new file mode 100644 index 0000000..c703b77 --- /dev/null +++ b/examples/memory_handoff/app/agents/researcher_agent.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# First half of a hand-off: an agent that researches a project and writes +# what it learned to long-term memory. +# +# Memory is scoped to (subject record, scope name) — not to the agent class +# — so anything this agent saves is readable by any other agent working on +# the same project. `save_memory` and `recall_memory` are ordinary +# function-calling tools, so the model decides when to write and when to +# read; you only decide what the subject is. +# +# Docs: https://docs.activeagents.ai/solid_agent/memory +class ResearcherAgent < ApplicationAgent + include SolidAgent::HasMemory + + generate_with :openai, model: "gpt-4o-mini" + + # scope: "default", class_name: "AgentMemory" unless you say otherwise. + # Use a scope to give one subject independent memory streams + # (has_memory scope: "competitive_research"). + has_memory + + def research + prompt( + message: "Research #{params[:project].name} and save what a writer would need to know.", + tools: memory_tool_definitions + ) + end + + # memory_subject defaults to params[:memorable], falling back to the + # HasContext contextable. Override it when the subject is somewhere else + # — here the project the run is about. + def memory_subject + params[:project] + end +end diff --git a/examples/memory_handoff/app/agents/writer_agent.rb b/examples/memory_handoff/app/agents/writer_agent.rb new file mode 100644 index 0000000..f570ee0 --- /dev/null +++ b/examples/memory_handoff/app/agents/writer_agent.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Second half of the hand-off: a different agent class, same subject. +# +# Two ways to pick up what ResearcherAgent left behind: +# +# 1. Give the model the recall tool and let it decide (below). +# 2. Prime the instructions with `memory.to_prompt` so the notes are in +# context from the first token — cheaper, and the model can't forget +# to look. `draft_with_primed_memory` does that. +# +# Docs: https://docs.activeagents.ai/solid_agent/memory +class WriterAgent < ApplicationAgent + include SolidAgent::HasMemory + + generate_with :openai, model: "gpt-4o-mini" + + has_memory + + def draft + prompt( + message: "Draft the launch post for #{params[:project].name}.", + tools: memory_tool_definitions + ) + end + + def draft_with_primed_memory + prompt( + instructions: [ + "You are a product writer.", + memory&.to_prompt + ].compact.join("\n\n"), + message: "Draft the launch post for #{params[:project].name}.", + tools: memory_tool_definitions + ) + end + + def memory_subject + params[:project] + end +end diff --git a/examples/memory_handoff/usage.rb b/examples/memory_handoff/usage.rb new file mode 100644 index 0000000..b35be62 --- /dev/null +++ b/examples/memory_handoff/usage.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Memory hand-off between two agents — rails console walkthrough. +# +# Docs: https://docs.activeagents.ai/solid_agent/memory + +project = Project.find(1) + +# The researcher calls save_memory as it works. Each note records which +# agent class wrote it. +ResearcherAgent.with(project: project).research.generate_now + +memory = AgentMemory.for(project) +memory.recall(limit: 5).map { |e| [ e.source_agent, e.category, e.content ] } +# => [["ResearcherAgent", "fact", "Ships on the 14th; pricing unchanged"], ...] + +# A different agent class, later — possibly a different request, job, or +# deploy — picks the same subject up and reads those notes back. +WriterAgent.with(project: project).draft.generate_now + +# Or hand the notes over without spending a tool call, by putting them in +# the instructions: +memory.to_prompt +# => "Memory notes for this subject:\n- Ships on the 14th... (ResearcherAgent)" + +WriterAgent.with(project: project).draft_with_primed_memory.generate_now + +# Scopes keep unrelated streams apart on the same subject: +AgentMemory.for(project, scope: "competitive_research").remember( + "Competitor X ships a similar feature in Q3", + source_agent: "MarketAgent", + category: "fact" +) + +# Categories filter recall; entries come back newest first. +memory.recall(category: "handoff", limit: 10) + +# Curation is ordinary Active Record — nothing here is append-only by +# force, so prune when a note goes stale. +memory.entries.where(category: "task").find_each(&:destroy) + +# The same tool contract is available to non-agent executors (a platform +# service, an MCP server) without including the concern: +SolidAgent::HasMemory.tool_definitions.map { |t| t[:name] } +# => ["save_memory", "recall_memory"] diff --git a/examples/persistent_conversation/app/agents/support_agent.rb b/examples/persistent_conversation/app/agents/support_agent.rb new file mode 100644 index 0000000..c3d7d2f --- /dev/null +++ b/examples/persistent_conversation/app/agents/support_agent.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# A support agent whose conversation outlives the request. +# +# `has_context :conversation, contextual: :user` persists the prompt, the +# assistant's reply, and the tool exchange in between to the tables the +# install generator created — agent_contexts, agent_messages and +# agent_generations — keyed by the record passed as params[:user]. +# +# The macro defines the methods used below (load_conversation, +# conversation_messages, conversation_result, conversation_summary, ...). +# Name the context something else and the methods rename with it. +# +# Docs: https://docs.activeagents.ai/solid_agent/context +class SupportAgent < ApplicationAgent + include SolidAgent::HasContext + + generate_with :openai, model: "gpt-4o-mini" + + # Naming a context also names the models it resolves: :conversation alone + # would look for Conversation / ConversationMessage / ConversationGeneration + # and raise NameError on the first request. class_name points it back at + # what `solid_agent:install` wrote (AgentMessage and AgentGeneration are + # inferred from it). Want separate tables instead? Run + # `rails generate solid_agent:context conversation` and drop class_name. + has_context :conversation, class_name: "AgentContext", contextual: :user + + # Multi-turn: load the stored conversation, append this turn's question, + # and send the whole history as the prompt. + # + # Nothing here writes to the database. With auto_save on (the default), + # SolidAgent persists the last prompt message as the user turn and the + # response as the assistant turn, both after the provider call — so the + # next request replays this exchange. Add messages by hand only with + # `auto_save: false`, or the turn is stored twice. + # + # `contextual: :user` alone would create the context for you, but it runs + # after the prompt is built — a conversation has to be loaded *before* + # that to replay its messages, so load it explicitly here. + def answer + load_conversation(contextable: params[:user]) + + prompt messages: conversation_messages + [ + { role: "user", content: params[:message] } + ] + end + + # Contexts are keyed by (contextable, agent_name, action_name), so a + # second action gets its own row rather than appending to the chat + # history. To work against an existing conversation from a different + # entry point, load it by id instead of by contextable. + def summarize + load_conversation(context_id: params[:conversation_id]) + + prompt messages: conversation_messages + [ + { role: "user", content: "Summarize this conversation in three bullet points." } + ] + end +end diff --git a/examples/persistent_conversation/app/controllers/support_conversations_controller.rb b/examples/persistent_conversation/app/controllers/support_conversations_controller.rb new file mode 100644 index 0000000..00f40ed --- /dev/null +++ b/examples/persistent_conversation/app/controllers/support_conversations_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# The web side of a persisted conversation: one POST per turn, and a show +# action that renders the history straight out of the database — no session +# state, no cache, nothing to warm up after a deploy. +class SupportConversationsController < ApplicationController + def show + @conversation = AgentContext + .for_agent("SupportAgent") + .for_action("answer") + .find_by!(contextable: current_user) + + @messages = @conversation.messages.chronological + end + + def create + response = SupportAgent.with( + user: current_user, + message: params.require(:message) + ).answer.generate_now + + render json: { reply: response.message.content } + end +end diff --git a/examples/persistent_conversation/app/views/agents/support/instructions.md.erb b/examples/persistent_conversation/app/views/agents/support/instructions.md.erb new file mode 100644 index 0000000..4a4a6fc --- /dev/null +++ b/examples/persistent_conversation/app/views/agents/support/instructions.md.erb @@ -0,0 +1,8 @@ +You are a support agent. + +Answer in two short paragraphs at most. When you do not know something, say +so and name the next step the customer should take rather than guessing. + +<%# Instructions are always rendered from this template. The action + template (answer.md.erb) is only a fallback for the message — pass + `messages:` to prompt, as SupportAgent#answer does, and it is skipped. %> diff --git a/examples/persistent_conversation/usage.rb b/examples/persistent_conversation/usage.rb new file mode 100644 index 0000000..11eadb5 --- /dev/null +++ b/examples/persistent_conversation/usage.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# Persistent conversation — rails console walkthrough. +# +# Docs: https://docs.activeagents.ai/solid_agent/context + +user = User.first + +# Turn one. The context row, the user message and the assistant message are +# all written during generate_now. +SupportAgent.with(user: user, message: "My invoice is wrong").answer.generate_now + +# Turn two. The agent replays turn one from the database before asking. +SupportAgent.with(user: user, message: "It's the VAT line").answer.generate_now + +conversation = AgentContext.for_agent("SupportAgent").find_by(contextable: user) + +conversation.messages.chronological.map { |m| [ m.role, m.content ] } +# => [["user", "My invoice is wrong"], +# ["assistant", "..."], +# ["user", "It's the VAT line"], +# ["assistant", "..."]] + +conversation.total_tokens # cumulative across both turns +conversation.generations.count # => 2, one row per provider call + +# Every generation carries what produced it: model, finish reason, token +# split, duration, raw provider payload, and a provenance snapshot of the +# agent/prompt/context checksums at the time. +generation = conversation.generations.last +generation.model # => "gpt-4o-mini" +generation.total_tokens +generation.estimated_cost # => USD estimate via SolidAgent::ModelPricing +generation.provenance["agent_checksum"] + +# Thread a distributed trace id through prompt_options and the generation +# joins up with your telemetry: +# +# def answer +# prompt_options[:trace_id] = Current.trace_id +# ... +# end +# +AgentGeneration.with_trace("some-trace-id") +AgentContext.with_trace("some-trace-id") + +# Reading a conversation back for a UI is plain Active Record — the +# generated models are yours, scopes included. +conversation.messages.assistant_messages.last&.content +AgentContext.for_agent("SupportAgent").recent.limit(10) +user.agent_contexts if user.respond_to?(:agent_contexts) # add the has_many yourself diff --git a/examples/reasoning/app/agents/analysis_agent.rb b/examples/reasoning/app/agents/analysis_agent.rb new file mode 100644 index 0000000..33c70e8 --- /dev/null +++ b/examples/reasoning/app/agents/analysis_agent.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Capturing extended thinking. +# +# Models that expose reasoning (Claude's extended thinking, OpenAI's +# reasoning models) return it alongside the answer. HasReasons collects it +# on the agent instance and, with `persist: true`, writes it onto the +# generation record so it survives the request — as long as that model +# includes SolidAgent::Reasonable and has the columns: +# +# rails generate solid_agent:reasons AgentGeneration +# rails db:migrate +# +# Reasoning is model output about its own process: treat it as sensitive. +# `redact_on_persist: true` keeps the token counts and drops the text. +# +# Docs: https://docs.activeagents.ai/solid_agent/reasoning +class AnalysisAgent < ApplicationAgent + include SolidAgent::HasContext + include SolidAgent::HasReasons + + generate_with :anthropic, model: "claude-sonnet-5" + + # Reasoning is read off the response, so it has to be handed to + # capture_reasoning once the provider has answered. Declared *before* + # has_context on purpose: around callbacks nest in declaration order, so + # this one wraps HasContext's — and by the time it runs, the generation + # row that `persist: true` updates has been written. + around_generation :capture_generation_reasoning + + has_context contextual: :document + + has_reasons auto_capture: true, # reasoning_prompt_options asks for thinking + persist: true, # store on the generation record + budget_tokens: 10_000, # default thinking budget + redact_on_persist: false # true stores "[Redacted]" + tokens + + def analyze + prompt( + message: "What risks does this contract create for the buyer?", + **reasoning_prompt_options # extended_thinking + budget from has_reasons + ) + end + + private + + def capture_generation_reasoning + response = yield + capture_reasoning(response) + response + end +end diff --git a/examples/reasoning/usage.rb b/examples/reasoning/usage.rb new file mode 100644 index 0000000..c723508 --- /dev/null +++ b/examples/reasoning/usage.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Reasoning capture — rails console walkthrough. +# +# Docs: https://docs.activeagents.ai/solid_agent/reasoning + +document = Document.find(1) + +AnalysisAgent.with(document: document).analyze.generate_now + +# Persisted (persist: true + SolidAgent::Reasonable on the model). This is +# what survives the request: +generation = AgentGeneration.recent.first +generation.reasoning_content +generation.reasoning_tokens +generation.has_reasoning? +generation.reasoning_summary(length: 120) +generation.thinking? # reasoning_tokens > 0 on the generated model + +# In-memory, on the agent instance that ran — reachable from inside an +# action or a callback, not from the console after the fact: +# +# def analyze +# prompt(...) +# end +# +# def after_response +# reasons # => [SolidAgent::Reasonable::Reason, ...] +# last_reasoning&.content +# total_reasoning_tokens +# has_reasoning? +# reasoning_chain # every non-redacted reason, joined +# reasoning_stats +# # => { count: 2, total_tokens: 450, total_thinking_time_ms: 1200, +# # redacted_count: 0, models: ["claude-sonnet-5"] } +# end + +# Storing reasoning by hand — from a provider response, or as a note: +generation.store_reasoning!(response) +generation.store_reason!( + SolidAgent::Reasonable::Reason.new(content: "Chose the strict parser", tokens: 0) +) + +# Reasoning columns on any generation-shaped model: +# +# rails generate solid_agent:reasons MyGeneration \ +# --content_column thinking_trace --tokens_column think_tokens +# +# class MyGeneration < ApplicationRecord +# include SolidAgent::Reasonable +# reasonable_config column: :thinking_trace, tokens_column: :think_tokens +# end diff --git a/examples/run_tracking/app/agents/report_agent.rb b/examples/run_tracking/app/agents/report_agent.rb new file mode 100644 index 0000000..f739ba1 --- /dev/null +++ b/examples/run_tracking/app/agents/report_agent.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# The agent the run records. Two details matter for run tracking: +# +# - the instructions are a constant, so the executor can fingerprint the +# exact text a run executed under (see DocumentAnalysisRun), +# - the caller's trace id is threaded into prompt_options, so the +# generation row, the context row and the run all carry the same +# trace_id and can be joined with your telemetry. +# +# Docs: https://docs.activeagents.ai/solid_agent/runs +class ReportAgent < ApplicationAgent + include SolidAgent::HasContext + + INSTRUCTIONS = <<~TEXT.freeze + You are a document analyst. Answer only from the document you are given, + quote the clause you are relying on, and say plainly when the document + does not cover the question. + TEXT + + generate_with :openai, model: "gpt-4o-mini" + + has_context contextual: :document + + def analyze + prompt_options[:trace_id] = params[:trace_id] if params[:trace_id] + + prompt instructions: INSTRUCTIONS, message: params[:question] + end +end diff --git a/examples/run_tracking/app/controllers/agent_runs_controller.rb b/examples/run_tracking/app/controllers/agent_runs_controller.rb new file mode 100644 index 0000000..d6460dc --- /dev/null +++ b/examples/run_tracking/app/controllers/agent_runs_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# The polling endpoint the progress stream exists for. Events are appended +# with update_column from the run's own thread, so a request mid-run reads +# whatever has landed so far. +class AgentRunsController < ApplicationController + def create + document = Document.find(params[:document_id]) + + run = AgentRun.create!( + runnable: document, + agent_name: "ReportAgent", + action_name: "analyze", + input_prompt: params[:question] + ) + + DocumentAnalysisJob.perform_later(run.id) + + render json: { id: run.id, status: run.status }, status: :accepted + end + + def show + run = AgentRun.find(params[:id]) + + render json: { + status: run.status, + in_progress: run.in_progress?, + events: run.events, + output: run.output, + error: run.error_message, + tokens: run.total_tokens, + duration_ms: run.calculated_duration_ms(fallback_end: Time.current) + } + end + + def destroy + run = AgentRun.find(params[:id]) + + # Returns false when the run already finished — cancellation is a + # request, not a guarantee. + render json: { cancelled: run.cancel! } + end +end diff --git a/examples/run_tracking/app/jobs/document_analysis_job.rb b/examples/run_tracking/app/jobs/document_analysis_job.rb new file mode 100644 index 0000000..9cfb256 --- /dev/null +++ b/examples/run_tracking/app/jobs/document_analysis_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Runs exist because the work happens somewhere the request can't watch. +class DocumentAnalysisJob < ApplicationJob + queue_as :default + + def perform(run_id) + run = AgentRun.find(run_id) + return if run.finished? # cancelled before a worker picked it up + + DocumentAnalysisRun.new(run).call + rescue StandardError + # The service already recorded the failure on the run; re-raising lets + # Active Job apply its own retry policy. + raise + end +end diff --git a/examples/run_tracking/app/services/document_analysis_run.rb b/examples/run_tracking/app/services/document_analysis_run.rb new file mode 100644 index 0000000..829ca93 --- /dev/null +++ b/examples/run_tracking/app/services/document_analysis_run.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# A durable record of one agent execution. +# +# AgentRun is the row a background job writes so a UI has something to +# poll: lifecycle status, the input, the output, token and duration +# accounting, an append-only progress stream, and an instructions +# fingerprint that groups runs into cohorts when you change the prompt. +# +# Nothing in SolidAgent creates these for you — the executor does, which is +# what this service is. It takes a run that already exists (the controller +# creates it so the client has an id to poll immediately) and drives it +# through its lifecycle. +# +# Docs: https://docs.activeagents.ai/solid_agent/runs +class DocumentAnalysisRun + def initialize(run) + @run = run + @document = run.runnable + @question = run.input_prompt + end + + def call + # Fingerprint the instructions this run executed under. Runs sharing a + # digest are one cohort — that is how "did the new prompt help?" + # becomes a comparable question. + @run.record_instructions(ReportAgent::INSTRUCTIONS) + @run.trace_id ||= SecureRandom.uuid + @run.save! + @run.start! + + # Progress events pair up by eid: "started" stays pending in the UI + # until a "done" or "error" with the same eid lands. + @run.append_event(kind: "llm", label: "analyze", eid: "gen-1", status: "started") + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + response = ReportAgent.with( + document: @document, + question: @question, + trace_id: @run.trace_id + ).analyze.generate_now + + @run.append_event( + kind: "llm", label: "analyze", eid: "gen-1", status: "done", + duration_ms: elapsed_ms(started) + ) + + @run.complete!( + output: response.message.content, + input_tokens: response.usage&.input_tokens, + output_tokens: response.usage&.output_tokens, + metadata: { model: "gpt-4o-mini" } + ) + + @run + rescue StandardError => e + # fail! records the message, stamps completed_at, computes duration. + @run.append_event(kind: "llm", label: "analyze", eid: "gen-1", status: "error", detail: e.message) + @run.fail!(e) + raise + end + + private + + def elapsed_ms(started) + ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round + end +end diff --git a/examples/run_tracking/usage.rb b/examples/run_tracking/usage.rb new file mode 100644 index 0000000..243b783 --- /dev/null +++ b/examples/run_tracking/usage.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# Runs, cohorts and cost — rails console walkthrough. +# +# Docs: https://docs.activeagents.ai/solid_agent/runs + +document = Document.find(1) + +# The controller creates the row so the client has an id to poll; here we +# do both halves in one go. +run = AgentRun.create!( + runnable: document, + agent_name: "ReportAgent", + action_name: "analyze", + input_prompt: "Who owns the IP?" +) + +DocumentAnalysisRun.new(run).call + +run.status # => "complete" +run.finished? # => true +run.total_tokens +run.duration_ms +run.output + +# The progress stream, oldest first. Each event is +# { at, eid, kind, label, status, detail, duration_ms }. +run.events +# => [{"at" => "2026-08-14T12:00:00.123Z", "eid" => "gen-1", "kind" => "llm", +# "label" => "analyze", "status" => "started"}, +# {"at" => "...", "eid" => "gen-1", "kind" => "llm", "label" => "analyze", +# "status" => "done", "duration_ms" => 1840}] + +# --- Cohorts ------------------------------------------------------------ + +# Runs are grouped by the instructions they executed under. The digest is +# stable; the codename is the readable form of the same value. +run.instructions_digest # => "a1b2c3d4" +run.instructions_codename # => "calm-heron" + +AgentRun.where(instructions_digest: run.instructions_digest).count + +# "Did the new instructions help?" — one row per cohort. +AgentRun.for_agent("ReportAgent").where(status: "complete") + .group(:instructions_digest) + .average(:duration_ms) + .transform_keys { |digest| SolidAgent::RunFingerprint.codename(digest) } +# => { "calm-heron" => 2400.0, "misty-atoll" => 1810.0 } + +# Fingerprinting without a run record: +SolidAgent::RunFingerprint.digest(ReportAgent::INSTRUCTIONS) +SolidAgent::RunFingerprint.codename("a1b2c3d4") + +# --- Scopes and correlation -------------------------------------------- + +AgentRun.recent.limit(20) +AgentRun.for_agent("ReportAgent").for_status("failed") +AgentRun.with_trace(run.trace_id) +AgentContext.with_trace(run.trace_id) +AgentGeneration.with_trace(run.trace_id) + +# --- Cost --------------------------------------------------------------- + +# Token counts are recorded; pricing is layered on top, so every figure is +# an estimate. Rates come from RubyLLM's registry when that gem is loaded +# and knows the model, and from a static pattern table otherwise. +SolidAgent::ModelPricing.estimate( + model: "claude-sonnet-5", input_tokens: 12_000, output_tokens: 800 +) +# => 0.048 + +SolidAgent::ModelPricing.rate_for("gpt-4o-mini") # => [0.15, 0.6] per 1M tokens + +# The generated AgentGeneration#estimated_cost uses it automatically, and +# takes explicit rates when you have negotiated your own. +AgentGeneration.recent.first.estimated_cost +AgentGeneration.recent.first.estimated_cost( + input_price_per_million: 0.10, output_price_per_million: 0.40 +) + +# Spend for a day, by model. Pricing is per-model, so total it in Ruby +# rather than in SQL: +AgentGeneration.where(created_at: 1.day.ago..) + .group_by(&:model) + .transform_values { |generations| generations.sum { |g| g.estimated_cost.to_f }.round(4) } diff --git a/examples/tool_streaming/app/agents/browser_agent.rb b/examples/tool_streaming/app/agents/browser_agent.rb new file mode 100644 index 0000000..6d75ec7 --- /dev/null +++ b/examples/tool_streaming/app/agents/browser_agent.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Rails does not guarantee net/http is loaded, and fetch_url below needs it. +require "net/http" + +# Tools three ways, plus live progress and result caching. +# +# - `has_tools :fetch_url` loads a schema from a JSON view template, so the +# schema lives next to the rest of the agent's views and can use ERB. +# - `tool :summarize_page do ... end` defines a schema inline. +# - `tool_description` wraps the tool method so each call broadcasts a +# human-readable status over ActionCable before it runs. +# - `SolidAgent::ToolCache.fetch` replays an identical call instead of +# paying for the side effect twice. +# +# Docs: https://docs.activeagents.ai/solid_agent/tools +class BrowserAgent < ApplicationAgent + include SolidAgent::HasTools + include SolidAgent::StreamsToolUpdates + + generate_with :openai, model: "gpt-4o-mini" + + # Loaded from app/views/browser_agent/tools/fetch_url.json.erb. + # `has_tools` with no arguments discovers every template in that + # directory instead. + has_tools :fetch_url + + tool :summarize_page do + description "Summarize text that was already fetched" + parameter :text, type: :string, required: true, description: "Page text to summarize" + parameter :sentences, type: :integer, default: 3 + end + + # Static or dynamic — a proc receives the tool's arguments. Declaring a + # description is what wraps the method for broadcasting; tools without + # one still run, they just stay quiet. + tool_description :fetch_url, ->(args) { "Fetching #{args[:url]}..." } + tool_description :summarize_page, "Summarizing the page..." + + def browse + # `tools` is every schema this agent declares: templates first, then + # inline definitions. + prompt tools: tools + end + + # Tool methods take keyword arguments and are named after the schema. + def fetch_url(url:) + # Identical (tool, args) pairs inside the TTL replay the stored result + # and come back tagged cached: true. Error-shaped results are never + # cached, so a transient failure doesn't stick for five minutes. + SolidAgent::ToolCache.fetch(tool: "fetch_url", args: { url: url }, ttl: 5.minutes) do + response = Net::HTTP.get_response(URI(url)) + + if response.is_a?(Net::HTTPSuccess) + { url: url, body: response.body.first(10_000) } + else + { error: "HTTP #{response.code}" } + end + end + end + + def summarize_page(text:, sentences: 3) + { summary: text.split(/(?<=\.)\s+/).first(sentences).join(" ") } + end +end diff --git a/examples/tool_streaming/app/channels/tool_status_channel.rb b/examples/tool_streaming/app/channels/tool_status_channel.rb new file mode 100644 index 0000000..48c5785 --- /dev/null +++ b/examples/tool_streaming/app/channels/tool_status_channel.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# The client half of StreamsToolUpdates. The agent broadcasts to whatever +# string it was handed as params[:stream_id], so the channel just streams +# from that name. +# +# // app/javascript/channels/tool_status_channel.js +# consumer.subscriptions.create( +# { channel: "ToolStatusChannel", stream_id: streamId }, +# { received({ tool_status }) { +# document.getElementById("status").textContent = tool_status.description +# } } +# ) +class ToolStatusChannel < ApplicationCable::Channel + def subscribed + stream_id = params[:stream_id].to_s + + # Scope the id to the current user so one subscriber can't listen in on + # another's run. + reject unless stream_id.start_with?("tool_status:#{current_user.id}:") + + stream_from stream_id + end +end diff --git a/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb b/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb new file mode 100644 index 0000000..f75965a --- /dev/null +++ b/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb @@ -0,0 +1,15 @@ +{ + "type": "function", + "name": "fetch_url", + "description": "Fetch a web page and return its text", + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Absolute http(s) URL to fetch" + } + }, + "required": ["url"] + } +} diff --git a/examples/tool_streaming/usage.rb b/examples/tool_streaming/usage.rb new file mode 100644 index 0000000..2245721 --- /dev/null +++ b/examples/tool_streaming/usage.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Tools, live status and caching — rails console walkthrough. +# +# Docs: https://docs.activeagents.ai/solid_agent/tools + +# What the agent will send to the provider: the template-loaded schema +# first, then the inline one. +BrowserAgent.new.tools.map { |t| t[:name] } +# => ["fetch_url", "summarize_page"] + +# Editing a JSON template while the server is running? Drop the cache: +agent = BrowserAgent.new +agent.reload_tools! + +# Without a stream_id nothing is broadcast — the same agent runs silently +# from a job or a console. +BrowserAgent.with(message: "Summarize https://rubyonrails.org").browse.generate_now + +# With one, every described tool announces itself before it runs: +# { tool_status: { name: "fetch_url", +# description: "Fetching https://rubyonrails.org...", +# timestamp: "2026-08-14T12:00:00Z" } } +stream_id = "tool_status:#{current_user.id}:#{SecureRandom.uuid}" + +BrowserAgent.with( + stream_id: stream_id, + message: "Summarize https://rubyonrails.org" +).browse.generate_now + +# --- Tool cache --------------------------------------------------------- + +# The cache is keyed by (tool, normalized args) — argument order and +# symbol/string keys don't change the key. +SolidAgent::ToolCache.cache_key("fetch_url", { url: "https://example.com" }) +# => "solid_agent:tool_cache:fetch_url:9f2c..." + +result = SolidAgent::ToolCache.fetch(tool: "fetch_url", args: { url: "https://example.com" }) do + { body: "expensive" } +end +result[:cached] # => nil on the first call, true on a replay + +# Backed by Rails.cache by default; swap the store (or switch it off) in +# tests and non-Rails runtimes. +SolidAgent::ToolCache.store = ActiveSupport::Cache::MemoryStore.new +SolidAgent::ToolCache.default_ttl = 60 +SolidAgent::ToolCache.enabled = false diff --git a/lib/generators/solid_agent/install/templates/agent_run.rb.erb b/lib/generators/solid_agent/install/templates/agent_run.rb.erb index e7a9907..f225e0a 100644 --- a/lib/generators/solid_agent/install/templates/agent_run.rb.erb +++ b/lib/generators/solid_agent/install/templates/agent_run.rb.erb @@ -67,9 +67,11 @@ class AgentRun < ApplicationRecord # 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. + # lands. update_column: no validations/callbacks, so this is safe to call + # from the run's own execution thread. + # + # Read-modify-write on a JSON column drops entries when two writers race, + # so the re-read and the write are serialized by a row lock. def append_event(kind:, label:, eid: nil, status: "done", detail: nil, duration_ms: nil) event = { "at" => Time.current.iso8601(3), @@ -80,8 +82,12 @@ class AgentRun < ApplicationRecord }.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 ]) + + with_lock do + current = self.class.where(id: id).pick(:events) || [] + update_column(:events, current + [ event ]) + end + event end diff --git a/lib/solid_agent/records/agent_run.rb b/lib/solid_agent/records/agent_run.rb index f795a30..bd83e02 100644 --- a/lib/solid_agent/records/agent_run.rb +++ b/lib/solid_agent/records/agent_run.rb @@ -280,8 +280,8 @@ def events_log # # Writes with +update_column+ — no validations, no callbacks, no # +updated_at+ churn — so it is safe to call from the run's own execution - # thread mid-transaction, and it re-reads the column from the database - # first so interleaved appends do not clobber each other. + # thread mid-transaction. The re-read and the write happen under a row + # lock, so an append racing another writer cannot drop either entry. # # @param kind [String, Symbol] "llm", "tool", "agent", … # @param label [String, Symbol] human-readable name of the thing happening @@ -301,7 +301,7 @@ def append_event(kind:, label:, eid: nil, status: "done", detail: nil, duration_ event["detail"] = truncated_detail(detail) if detail event["duration_ms"] = duration_ms if duration_ms - update_column(events_attribute, current_events + [ event ]) + append_to_events_log(event) event end @@ -326,8 +326,7 @@ def add_log(message, level: :info) "message" => message.to_s } - update!(events_attribute => current_events + [ entry ]) - entry + append_to_events_log(entry, save: true) end # === Cohort fingerprinting === @@ -479,6 +478,34 @@ def current_events self.class.where(id: id).pick(events_attribute) || [] end + # Read-modify-write on a JSON column loses entries when two writers + # race — a tool loop appending progress while the run's own thread + # logs, say: both read the same array and the second write wins. The + # read and the write are serialized by a row lock so the append is + # always against the latest persisted value. + # + # An unsaved record has no row to lock; it keeps the in-memory + # behaviour current_events already falls back to. + # + # @param entry [Hash] event or log entry to append + # @param save [Boolean] true runs validations and callbacks (add_log), + # false writes the column directly (append_event, called from the + # run's own execution thread) + def append_to_events_log(entry, save: false) + unless persisted? + write_attribute(events_attribute, current_events + [ entry ]) + return entry + end + + with_lock do + appended = current_events + [ entry ] + + save ? update!(events_attribute => appended) : update_column(events_attribute, appended) + end + + entry + end + def truncated_detail(detail) detail.to_s.byteslice(0, DETAIL_LIMIT).to_s.scrub end diff --git a/test/examples_test.rb b/test/examples_test.rb new file mode 100644 index 0000000..0449a21 --- /dev/null +++ b/test/examples_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "test_helper" + +# The examples/ directory is documentation that ships as code, so it gets +# the checks documentation can't have: every Ruby file has to parse, every +# manifest has to validate against the real validator, and every example +# has to be reachable from the index. +class ExamplesTest < Minitest::Test + EXAMPLES_ROOT = File.expand_path("../examples", __dir__) + + def test_every_ruby_example_parses + ruby_files = Dir.glob(File.join(EXAMPLES_ROOT, "**", "*.rb")) + + refute_empty ruby_files, "expected examples/ to contain Ruby files" + + ruby_files.each do |path| + RubyVM::InstructionSequence.compile(File.read(path, encoding: "UTF-8"), path) + rescue SyntaxError => e + flunk "#{relative(path)} does not parse: #{e.message}" + end + end + + def test_every_manifest_example_is_valid + manifests = Dir.glob(File.join(EXAMPLES_ROOT, "**", "*.agent.md")) + + refute_empty manifests, "expected examples/ to contain a .agent.md manifest" + + manifests.each do |path| + errors = SolidAgent::AgentManifest.validate(path) + + assert_empty errors, "#{relative(path)} is not a valid manifest: #{errors.join('; ')}" + end + end + + def test_every_example_is_listed_in_the_index + index = File.read(File.join(EXAMPLES_ROOT, "README.md"), encoding: "UTF-8") + + example_dirs.each do |dir| + assert_includes index, "(#{dir})", + "examples/README.md does not link to examples/#{dir}" + end + end + + def test_examples_do_not_reference_removed_apis + # Cheap guard against the examples drifting from the concerns they + # document: every SolidAgent constant they name has to exist. + referenced = Dir.glob(File.join(EXAMPLES_ROOT, "**", "*.rb")) + .flat_map { |path| File.read(path, encoding: "UTF-8").scan(/SolidAgent::([A-Z][A-Za-z:]*)/) } + .flatten + .uniq + + refute_empty referenced + + referenced.each do |constant| + # const_defined? takes a qualified name ("Reasonable::Reason"), but + # raises on a malformed one — a trailing "::" swept up by the regex + # should read as a test failure, not an error. + defined_here = begin + SolidAgent.const_defined?(constant) + rescue NameError + false + end + + assert defined_here, + "examples reference SolidAgent::#{constant}, which does not exist" + end + end + + private + + def example_dirs + Dir.children(EXAMPLES_ROOT) + .select { |entry| File.directory?(File.join(EXAMPLES_ROOT, entry)) } + .sort + end + + def relative(path) + path.delete_prefix("#{File.dirname(EXAMPLES_ROOT)}/") + end +end diff --git a/test/records/agent_run_test.rb b/test/records/agent_run_test.rb index 782e209..1934dfd 100644 --- a/test/records/agent_run_test.rb +++ b/test/records/agent_run_test.rb @@ -417,6 +417,41 @@ def setup assert_equal [ "first", "from another thread", "third" ], run.reload.events_log.map { |e| e["label"] } end + test "append_event serializes the read and the write" do + run = create_run + + # Re-reading is not enough on its own: two writers that read the same + # array before either writes lose one entry. The append runs inside a + # row lock, so the second writer's read happens after the first commits. + other = AgentRun.find(run.id) + other.events_log # prime the stale in-memory copy + + run.append_event(kind: "llm", label: "first") + other.append_event(kind: "tool", label: "second") + + assert_equal [ "first", "second" ], run.reload.events_log.map { |e| e["label"] } + end + + test "add_log does not drop a concurrently appended event" do + run = create_run + stale = AgentRun.find(run.id) + stale.events_log + + run.append_event(kind: "llm", label: "event") + stale.add_log("log line") + + assert_equal [ "event", "log line" ], + run.reload.events_log.map { |entry| entry["label"] || entry["message"] } + end + + test "append_event on an unsaved run keeps the entry in memory" do + run = AgentRun.new(agent: @agent) + + run.append_event(kind: "llm", label: "before save") + + assert_equal [ "before save" ], run.events_log.map { |e| e["label"] } + end + test "add_log appends a log entry through a normal save" do run = create_run