From 17193254b7cbce08943e929823a42d432d95be2f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:37:29 +0000 Subject: [PATCH 1/5] Add worked examples for every SolidAgent concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gem's README shows fragments; nothing showed a concern in the shape it takes inside an app. examples/ now has one worked example per concern — agent classes, views, channels, controllers, jobs and console walkthroughs laid out in Rails paths — and examples_test.rb keeps them honest: every Ruby file parses, every .agent.md validates against the real validator, every SolidAgent constant they name exists, and every example is linked from the index. Also fixes the README's HasContext example, which added the user message by hand and then let auto_save persist the same turn a second time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NGn2NzpzFZT4JGKBdFMpxh --- README.md | 35 ++++++- examples/README.md | 60 ++++++++++++ examples/manifests/changelog_writer.agent.md | 81 ++++++++++++++++ examples/manifests/usage.rb | 96 +++++++++++++++++++ .../app/agents/researcher_agent.rb | 36 +++++++ .../memory_handoff/app/agents/writer_agent.rb | 41 ++++++++ examples/memory_handoff/usage.rb | 45 +++++++++ .../app/agents/support_agent.rb | 53 ++++++++++ .../support_conversations_controller.rb | 24 +++++ .../views/agents/support/instructions.md.erb | 8 ++ examples/persistent_conversation/usage.rb | 51 ++++++++++ .../reasoning/app/agents/analysis_agent.rb | 52 ++++++++++ examples/reasoning/usage.rb | 52 ++++++++++ .../run_tracking/app/agents/report_agent.rb | 30 ++++++ .../app/controllers/agent_runs_controller.rb | 43 +++++++++ .../app/jobs/document_analysis_job.rb | 17 ++++ .../app/services/document_analysis_run.rb | 68 +++++++++++++ examples/run_tracking/usage.rb | 85 ++++++++++++++++ .../app/agents/browser_agent.rb | 62 ++++++++++++ .../app/channels/tool_status_channel.rb | 24 +++++ .../browser_agent/tools/fetch_url.json.erb | 15 +++ examples/tool_streaming/usage.rb | 47 +++++++++ test/examples_test.rb | 72 ++++++++++++++ 23 files changed, 1094 insertions(+), 3 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/manifests/changelog_writer.agent.md create mode 100644 examples/manifests/usage.rb create mode 100644 examples/memory_handoff/app/agents/researcher_agent.rb create mode 100644 examples/memory_handoff/app/agents/writer_agent.rb create mode 100644 examples/memory_handoff/usage.rb create mode 100644 examples/persistent_conversation/app/agents/support_agent.rb create mode 100644 examples/persistent_conversation/app/controllers/support_conversations_controller.rb create mode 100644 examples/persistent_conversation/app/views/agents/support/instructions.md.erb create mode 100644 examples/persistent_conversation/usage.rb create mode 100644 examples/reasoning/app/agents/analysis_agent.rb create mode 100644 examples/reasoning/usage.rb create mode 100644 examples/run_tracking/app/agents/report_agent.rb create mode 100644 examples/run_tracking/app/controllers/agent_runs_controller.rb create mode 100644 examples/run_tracking/app/jobs/document_analysis_job.rb create mode 100644 examples/run_tracking/app/services/document_analysis_run.rb create mode 100644 examples/run_tracking/usage.rb create mode 100644 examples/tool_streaming/app/agents/browser_agent.rb create mode 100644 examples/tool_streaming/app/channels/tool_status_channel.rb create mode 100644 examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb create mode 100644 examples/tool_streaming/usage.rb create mode 100644 test/examples_test.rb diff --git a/README.md b/README.md index c585c4a..7468f00 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ 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: @@ -61,9 +65,11 @@ class WritingAssistantAgent < ApplicationAgent has_context :conversation, 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 +81,11 @@ 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. + > **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. @@ -220,6 +231,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: 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..4f1a2ea --- /dev/null +++ b/examples/persistent_conversation/app/agents/support_agent.rb @@ -0,0 +1,53 @@ +# 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" + + has_context :conversation, 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..619634c --- /dev/null +++ b/examples/tool_streaming/app/agents/browser_agent.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# 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/test/examples_test.rb b/test/examples_test.rb new file mode 100644 index 0000000..94692bb --- /dev/null +++ b/test/examples_test.rb @@ -0,0 +1,72 @@ +# 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| + assert SolidAgent.const_defined?(constant), + "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 From fe04f210531014fac23e287e23bfb4013bd125b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 16:40:04 +0000 Subject: [PATCH 2/5] Add CI, release automation, badges and a LICENSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repository had no workflows at all: no test run on a pull request, no release automation, and a gemspec pointing at a LICENSE file that was never committed. Releases were a local `rake release` against a gem whose suite nobody had run. - ci.yml runs the unit suite on Ruby 3.1/3.3/3.4, plus a cross-repo job that runs this working tree through activeagent's integration suite — against its main branch and its latest release tag, with SOLID_AGENT_STRICT=1 so nothing silently skips. This suite mocks everything by design, so it can pass while the concerns no longer compose with the framework they extend; that job is what notices. - release.yml publishes from a v* tag through RubyGems trusted publishing (OIDC, no stored key), gated on CI, skipping versions already published. Needs a trusted publisher configured for solid_agent on rubygems.org. - README gains version, downloads, CI, docs, Ruby and license badges, plus how to run the cross-repo suite and how releases go out. Also documents the has_context naming trap the cross-repo suite surfaced: a named context resolves models named after it, not the AgentContext family the installer writes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NGn2NzpzFZT4JGKBdFMpxh --- .github/workflows/ci.yml | 137 ++++++++++++++++++ .github/workflows/release.yml | 98 +++++++++++++ LICENSE | 21 +++ README.md | 60 +++++++- .../app/agents/support_agent.rb | 8 +- 5 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f11503a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,137 @@ +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 floor is 3.0; the ends of the supported range plus + # the version most contributors are on. + ruby: [ "3.1", "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" + 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 + + - 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 + working-directory: activeagent + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/activeagent/gemfiles/solid_agent_main.gemfile + SOLID_AGENT_PATH: ${{ github.workspace }}/solid_agent + + - name: Setup database + 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 + 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() + 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/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 7468f00..40b28b5 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,14 @@ # 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)** · @@ -62,7 +70,7 @@ 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: params[:user]) # contextable is the polymorphic association @@ -86,6 +94,14 @@ 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. @@ -215,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 @@ -260,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/persistent_conversation/app/agents/support_agent.rb b/examples/persistent_conversation/app/agents/support_agent.rb index 4f1a2ea..c3d7d2f 100644 --- a/examples/persistent_conversation/app/agents/support_agent.rb +++ b/examples/persistent_conversation/app/agents/support_agent.rb @@ -17,7 +17,13 @@ class SupportAgent < ApplicationAgent generate_with :openai, model: "gpt-4o-mini" - has_context :conversation, contextual: :user + # 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. From 8d24b1844ae80911848636c69106c0c1890087ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 16:54:57 +0000 Subject: [PATCH 3/5] Resync Gemfile.lock and drop Ruby 3.1 from the matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the new CI surfaced on its first run, both pre-existing: Gemfile.lock recorded activeagent (>= 1.1.0) for the path gem while the gemspec asks for >= 1.0.0, so any frozen install — which is what setup-ruby's bundler-cache does — refused to proceed. Nothing had run a deployment-mode install before, so the drift was invisible. Bundler 2.7.2, which the lockfile pins, requires Ruby >= 3.2, so the 3.1 job could not get as far as installing gems. The gemspec still claims >= 3.0.0; 3.2 is the floor CI can actually prove, and the matrix now says so rather than failing on a promise the repo cannot keep. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NGn2NzpzFZT4JGKBdFMpxh --- .github/workflows/ci.yml | 8 +++++--- Gemfile.lock | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f11503a..f622a76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,11 @@ jobs: strategy: fail-fast: false matrix: - # The gemspec floor is 3.0; the ends of the supported range plus - # the version most contributors are on. - ruby: [ "3.1", "3.3", "3.4" ] + # 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 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) From 26b69b7a750b8b7fb39e210c0047f396f8c4e0bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 16:57:39 +0000 Subject: [PATCH 4/5] Skip the cross-repo job when activeagent predates the harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gemfiles/solid_agent_main.gemfile and test/integration/solid_agent live in activeagent, so any revision without them has nothing to run — every release tag until one ships with the harness, and main until activeagents/activeagent#364 merges. Both were hard-failing on a missing BUNDLE_GEMFILE, which reads as "this gem is broken" rather than "the other side is too old". The job now checks for the harness first and skips with that reason in the step summary. The latest-release entry will keep skipping until a release carries the suite, which is the honest state of it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NGn2NzpzFZT4JGKBdFMpxh --- .github/workflows/ci.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f622a76..dca4706 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,12 +90,29 @@ jobs: 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 @@ -105,6 +122,7 @@ jobs: 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 @@ -117,6 +135,7 @@ jobs: # 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 @@ -126,7 +145,7 @@ jobs: actionagent/test/agent_execution_service_test.rb - name: Report the resolved versions - if: always() + if: always() && steps.harness.outputs.present == 'true' working-directory: activeagent env: BUNDLE_GEMFILE: ${{ github.workspace }}/activeagent/gemfiles/solid_agent_main.gemfile From 61e8da40bd24a11b50953776d2e8051a389743b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 17:04:21 +0000 Subject: [PATCH 5/5] Serialize event-log appends against a row lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's review caught a real lost update: append_event reads the events column, adds an entry, and writes the result, so two writers that read before either writes leave only one entry behind. A tool loop appending progress while the run's own thread logs is exactly that shape. The docstring claimed concurrent appends "interleave safely", which is what re-reading buys on its own — it narrows the window, it does not close it. The read and the write now happen inside with_lock, in Records::AgentRun and in the install generator's template alike, and add_log goes through the same path rather than carrying its own copy of the race. An unsaved run has no row to lock and keeps the in-memory behaviour. Also from the same review: require net/http in the browser agent example, since Rails does not guarantee it is loaded; make the examples' constant check fail rather than error on a malformed capture; and record why the cross-repo job sets placeholder API keys. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NGn2NzpzFZT4JGKBdFMpxh --- .github/workflows/ci.yml | 6 +++ .../app/agents/browser_agent.rb | 3 ++ .../install/templates/agent_run.rb.erb | 16 +++++--- lib/solid_agent/records/agent_run.rb | 37 ++++++++++++++++--- test/examples_test.rb | 11 +++++- test/records/agent_run_test.rb | 35 ++++++++++++++++++ 6 files changed, 97 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dca4706..0738400 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,12 @@ jobs: 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 diff --git a/examples/tool_streaming/app/agents/browser_agent.rb b/examples/tool_streaming/app/agents/browser_agent.rb index 619634c..6d75ec7 100644 --- a/examples/tool_streaming/app/agents/browser_agent.rb +++ b/examples/tool_streaming/app/agents/browser_agent.rb @@ -1,5 +1,8 @@ # 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 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 index 94692bb..0449a21 100644 --- a/test/examples_test.rb +++ b/test/examples_test.rb @@ -53,7 +53,16 @@ def test_examples_do_not_reference_removed_apis refute_empty referenced referenced.each do |constant| - assert SolidAgent.const_defined?(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 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