From d3f3b02eb499ae30d2133420e43d7a1d38826db1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 16:47:02 +0000 Subject: [PATCH] feat(dashboard): give Agent a generations association MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ActionAgent::Agent` declared five associations but not the one a host most often wants. Generations hang off `AgentContext` polymorphically, so reading them meant knowing that shape and writing the join by hand: AgentGeneration.joins(:agent_context) .where(AgentContext.table_name => { contextable: agent }) The engine already wrote exactly that, in `EvaluationRunnerService#sample_generations` — but as a private method on a service, so a host could not reuse it and copied the join instead, coupling itself to an implementation detail of how contexts are modelled. A change to that shape would then break host code silently rather than at the engine boundary. `agent.generations` is now that one definition, and `sample_generations` uses it. It stays a scope, so `agent.generations.count` answers the "has this agent actually run?" question the dashboard's own views had no cheap way to ask. Deliberately no `dependent:` on `agent_contexts`. The issue suggested `dependent: :destroy` and the sibling associations use it, but destroying an agent has never taken its conversations with it, and quietly starting to delete recorded history in a patch release is not something an association added for reading should do. A test pins the current behaviour; changing it is a separate call. Closes #464 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J9cRpVTtxA8QZBTmnYSC7V --- CHANGELOG.md | 6 +++ actionagent/app/models/action_agent/agent.rb | 8 +++ .../action_agent/evaluation_runner_service.rb | 4 +- .../agent_generations_association_test.rb | 49 +++++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 actionagent/test/agent_generations_association_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 73dc3701..bff05879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ Releases `activeagent` and `actionagent` 1.6.3 from one tag. - `Evaluation#replace_scenarios!` takes `on_removed:` — `:destroy` (the default, unchanged) or `:disable`, which keeps a scenario the suite no longer names as `enabled: false` so earlier runs' results still resolve. +- `Agent#generations` reads the generations recorded against an agent, with + `Agent#agent_contexts` beside it. Generations hang off `AgentContext` + polymorphically, so reaching them meant hand-writing that join — the engine + did it itself in a private service method a host could not reuse, which now + uses the association instead. Destroying an agent still leaves its contexts + alone, as it always has. (#464) ### Fixed diff --git a/actionagent/app/models/action_agent/agent.rb b/actionagent/app/models/action_agent/agent.rb index b049be63..51da1451 100644 --- a/actionagent/app/models/action_agent/agent.rb +++ b/actionagent/app/models/action_agent/agent.rb @@ -11,6 +11,14 @@ class ObservedAgentError < StandardError; end has_many :agent_runs, dependent: :destroy has_many :evaluations, dependent: :destroy has_many :agent_memories, as: :memorable, dependent: :destroy + # Generations hang off AgentContext polymorphically, which is an + # implementation detail of how contexts are modelled — so without these a + # host that wants an agent's recorded history writes that join itself and is + # coupled to the shape. Deliberately no `dependent:` on the contexts: the + # association is added to read them, and destroying an agent has never taken + # its conversations with it. Making it do so is a separate call. + has_many :agent_contexts, as: :contextable + has_many :generations, through: :agent_contexts # Polymorphic rows (agent_memories, agent_contexts) store this string. # A host app that grew these tables under its own Agent constant keeps diff --git a/actionagent/app/services/action_agent/evaluation_runner_service.rb b/actionagent/app/services/action_agent/evaluation_runner_service.rb index c3b03767..d1496e6f 100644 --- a/actionagent/app/services/action_agent/evaluation_runner_service.rb +++ b/actionagent/app/services/action_agent/evaluation_runner_service.rb @@ -149,9 +149,7 @@ def passed_count(per_sample_scores) end def sample_generations(model: nil) - scope = AgentGeneration - .joins(:agent_context) - .where(AgentContext.table_name => { contextable: @evaluation.agent }) + scope = @evaluation.agent.generations scope = scope.where(model: model) if model scope.order(created_at: :desc).limit(@evaluation.sample_size).to_a end diff --git a/actionagent/test/agent_generations_association_test.rb b/actionagent/test/agent_generations_association_test.rb new file mode 100644 index 00000000..04b3a546 --- /dev/null +++ b/actionagent/test/agent_generations_association_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "test_helper" + +# Generations hang off AgentContext polymorphically, so reading an agent's +# recorded history meant writing that join by hand — the engine's own +# EvaluationRunnerService did, in a private method a host cannot reuse. The +# association makes it one definition and keeps the shape behind the engine +# boundary. +class ActionAgentAgentGenerationsAssociationTest < ActiveSupport::TestCase + def setup + @agent = ActionAgent::Agent.create!(name: "Support", provider: "mock", model: "mock") + @other = ActionAgent::Agent.create!(name: "Other", provider: "mock", model: "mock") + end + + def generation_for(agent, model:, content: "hi") + context = ActionAgent::AgentContext.create!(contextable: agent, agent_name: "SupportAgent", action_name: "respond") + ActionAgent::AgentGeneration.create!(agent_context: context, model: model, content: content) + end + + test "an agent reads the generations recorded against it" do + mine = generation_for(@agent, model: "gpt-5.5") + theirs = generation_for(@other, model: "gpt-5.5") + + assert_equal [ mine.id ], @agent.generations.pluck(:id) + assert_equal [ theirs.id ], @other.generations.pluck(:id) + end + + test "generations is a scope, so it counts and filters without loading" do + generation_for(@agent, model: "gpt-5.5") + generation_for(@agent, model: "claude-opus-5") + + assert_equal 2, @agent.generations.count + assert_equal 1, @agent.generations.where(model: "gpt-5.5").count + # The dashboard's "has this agent actually run?" question. + assert_equal 0, @other.generations.count + end + + # The association exists to read generations. Destroying an agent has never + # taken its contexts with it, and this must not quietly start doing so. + test "destroying an agent leaves its contexts alone, as before" do + generation_for(@agent, model: "gpt-5.5") + context_count = ActionAgent::AgentContext.count + + @agent.destroy! + + assert_equal context_count, ActionAgent::AgentContext.count + end +end