Skip to content
Open
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `ActiveAgent::Base.rendered_instructions` renders an agent's instructions
outside a generation, for a dashboard mirroring the class and for tests
asserting what a model is told. Both otherwise reached a private renderer
through `send`.
- `ActionAgent::AgentSync` mirrors host agent classes into dashboard `Agent`
records, setting the `agent_class_name` that `AgentRelease` already expects a
host to have written. The code owns what an agent is (name, description,
instructions, tools — rewritten every sync); the operator owns how it runs
(provider, model, status — set on create and preserved), so a model chosen in
the dashboard survives the next deploy.
- `ActionAgent.run_host_agent_classes` (default `false`) runs an agent that
mirrors a host class as that class, rather than as one rebuilt from the
record's `tools` and `instructions` columns. Dashboard-authored agents, which
name no class, keep using the dynamic runtime either way; a class name that no
longer resolves falls back to it rather than failing the run.

### Fixed

- A Rails enum is offered to the model as its names (`{type: "string", enum:
[...]}`) instead of the integer backing it. `SchemaGenerator` reads enums from
inclusion validators and never consulted `defined_enums`, so a `status` column
reached the model as a bare integer with no labels — leaving every host to
explain the mapping in prose.
- A filter value outside an enum is rejected, naming the valid values, instead
of matching no rows. `status: "pending"` returned `{count: 0}`, which an agent
reports as a fact — indistinguishable from "none match". Same reasoning as the
unknown-operator rejection in `range_predicates!`.
- An enum is no longer offered the range form. Its integer backing is a
declaration-order artefact, so `status: {gt: 1}` was a meaningless filter that
still returned a confident count.

## [1.6.3] - 2026-09-18

Releases `activeagent` and `actionagent` 1.6.3 from one tag.
Expand Down
49 changes: 49 additions & 0 deletions actionagent/app/services/action_agent/agent_execution_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,21 @@ def generate!
tool_definitions = tool_schemas
service = self

# A dashboard-authored agent has no Ruby class — it is rows: a tool
# selection, instructions typed in the builder. That is the common case
# and the runtime below builds a class for it.
#
# An agent mirrored from host code is the other case: the class exists,
# already declares its own tools (SchemaTools rosters, delegations) and
# renders its own instructions, and none of that is reachable through
# `tools` + `instructions` columns. Running the real class keeps the
# dashboard evaluating what production runs, instead of a rebuilt
# lookalike. Both runtimes stay; which one applies is decided by whether
# the class resolves.
if (host_class = resolved_host_class)
return run_host_class(host_class, actor: actor, action: action, run_trace_id: run_trace_id)
end

agent_class = Class.new(ActiveAgent::Base) do
# SolidAgent persists contexts under self.class.name; anonymous
# classes would fail its agent_name presence validation.
Expand Down Expand Up @@ -677,6 +692,40 @@ def record_tool_spans(root_span, response)
end
end

# The host class this agent mirrors, when it names one that resolves to a
# runnable ActiveAgent::Base subclass. Anything else — no class name, a
# class that no longer exists, a name that resolves to something else — is
# nil, and the dynamic runtime handles the record as before.
#
# @return [Class, nil]
def resolved_host_class
return nil unless ActionAgent.run_host_agent_classes

name = @agent_record.agent_class_name.presence
return nil if name.blank?

klass = name.safe_constantize
klass if klass.is_a?(Class) && klass < ActiveAgent::Base
end

# Runs the host's own class. Its tools, delegations and instructions come
# from the code, so the engine supplies only what is the run's business:
# the caller, and the trace to correlate against.
def run_host_class(klass, actor:, action:, run_trace_id:)
generation = klass.as(actor).public_send(action, **host_action_arguments(klass, action))
generation.prompt_options[:trace_id] = run_trace_id if generation.respond_to?(:prompt_options)
generation.generate_now
end

# A code agent's action takes named arguments (`ask(question:)`), so the
# run's message is passed under the action's own keyword rather than as a
# bare message the signature would reject.
def host_action_arguments(klass, action)
contract = klass.try(:delegation_contracts)&.dig(action.to_sym)
keyword = contract&.try(:parameters)&.keys&.first
keyword ? { keyword.to_sym => user_text } : {}
end

def provider_available?(name)
# The gem's mock provider is a test double: accepted only in the test
# environment so app runs can never store fabricated output.
Expand Down
134 changes: 134 additions & 0 deletions actionagent/app/services/action_agent/agent_sync.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# frozen_string_literal: true

module ActionAgent
# Mirrors host ActiveAgent classes into Agent records, so the dashboard can
# run, evaluate and release the agents an app already has in code.
#
# The engine reads an agent's identity from the class rather than asking the
# host to restate it: name, description and tool roster all come from the
# class, and re-running the sync updates each record in place. `AgentRelease`
# already expects `agent_class_name` to be set by "whatever syncs its
# ActiveAgent classes into Agent records" — this is that, so a host no longer
# has to write it.
#
# The split is deliberate and is the reason this is safe to run on deploy:
#
# * **The code owns what an agent is** — name, description, instructions,
# tools. Rewritten on every sync, so it cannot drift from the class.
# * **The operator owns how it runs** — provider, model, status. Set once on
# create and never touched again, so a model chosen in the dashboard
# survives the next deploy.
#
# ActionAgent::AgentSync.call(RecordAgent.all, owner: owner)
#
# @see AgentRelease which cuts a version per synced agent
class AgentSync
Row = Struct.new(:agent, :created, :skipped, keyword_init: true)
Result = Struct.new(:rows, :errors, keyword_init: true) do
def success? = errors.blank?
def agents = rows.filter_map(&:agent)
def created = rows.select(&:created)
def skipped = rows.select(&:skipped)
end

# @param agents [Array<Class>] ActiveAgent::Base subclasses
# @param owner [Object] the record agents and API keys scope to
# @param provider [String, Symbol, nil] defaults to the class's own
# @param model [String, nil] defaults to the class's own
def self.call(agents, owner:, provider: nil, model: nil)
new(agents, owner: owner, provider: provider, model: model).call
end

def initialize(agents, owner:, provider: nil, model: nil)
@agents = Array(agents)
@owner = owner
@provider = provider
@model = model
end

# @return [Result]
def call
return Result.new(rows: [], errors: "An owner is required: the engine scopes agents to one.") if @owner.nil?

rows = Agent.transaction { @agents.map { |klass| upsert(klass) } }
Result.new(rows: rows, errors: nil)
rescue ActiveRecord::RecordInvalid => e
Result.new(rows: [], errors: e.record.errors.full_messages.join(", "))
end

private

def upsert(klass)
unless klass.respond_to?(:prompt_options)
return Row.new(agent: nil, created: false, skipped: "#{klass} is not an ActiveAgent::Base subclass")
end

provider = resolved_provider(klass)
model = resolved_model(klass)
if provider.blank? || model.blank?
return Row.new(agent: nil, created: false, skipped: "#{klass} has no provider/model configured")
end

agent = Agent.find_or_initialize_by(slug: self.class.slug_for(klass))
created = agent.new_record?
if created
agent.owner = @owner
agent.provider = provider
agent.model = model
agent.status = :active
end

agent.assign_attributes(
name: klass.name.titleize,
agent_class_name: klass.name,
description: description_for(klass),
instructions: instructions_for(klass),
tools: tool_names_for(klass)
)
agent.save!
Row.new(agent: agent, created: created, skipped: nil)
end

# "TicketAgent" -> "ticket-agent", the slug an MCP client sees as
# run_ticket-agent. A namespaced class flattens its separators, because
# Agent validates slugs as /\A[a-z0-9\-_]+\z/ — "Billing::TicketAgent"
# becomes "billing-ticket-agent".
def self.slug_for(klass)
klass.name.underscore.tr("/", "-").tr("_", "-")
end

# An agent's own description if it declares one (a delegation contract is
# where an agent says what it answers), else its titleized name.
def description_for(klass)
contract = klass.try(:delegation_contracts)&.values&.first
contract&.try(:description).presence || klass.name.titleize
end

# The rendered instructions, so the dashboard record runs on the same text
# the class does rather than a hand-maintained copy.
#
# An agent whose instructions are assembled rather than rendered straight
# from its own template — filled from assigns it computes, or falling back
# to a template it shares with sibling agents — says so by defining
# `dashboard_instructions_text`. That is asked first, because only the
# class knows how its own prompt is built.
def instructions_for(klass)
return klass.dashboard_instructions_text.presence if klass.respond_to?(:dashboard_instructions_text)

klass.try(:rendered_instructions).presence
end

def tool_names_for(klass)
names = klass.try(:tool_names)
Array(names).map(&:to_s)
end

def resolved_provider(klass)
(@provider || klass.prompt_options[:service]).to_s.downcase.presence
end

def resolved_model(klass)
(@model || klass.prompt_options[:model]).presence
end
end
end
16 changes: 16 additions & 0 deletions actionagent/lib/action_agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,21 @@ def deprecator
# @return [Boolean]
attr_accessor :execution_enabled

# Whether a run of an agent that mirrors a host class executes that class,
# instead of the class the engine builds from the record's `tools` and
# `instructions` columns.
#
# Off by default: it changes what a run of a mirrored agent executes, and
# a host that has tuned its dashboard records around the dynamic runtime
# should opt in deliberately. Dashboard-authored agents — the ones with no
# `agent_class_name` — are unaffected either way.
#
# On, a mirrored agent runs its real tools, delegations and instructions,
# so an evaluation scores the agent production runs rather than a
# flattened copy of it.
# @return [Boolean]
attr_accessor :run_host_agent_classes

# Whether the "Ask ActiveAgents" assistant is available.
#
# The assistant is a tool for developing and CI-ing agents: it sends
Expand Down Expand Up @@ -544,6 +559,7 @@ def reset!
@provider_credentials_resolver = nil
@sandbox_backends = {}
@execution_enabled = true
@run_host_agent_classes = false
@assistant_enabled = nil

@scenario_evaluation_adapter_resolver = nil
Expand Down
89 changes: 89 additions & 0 deletions actionagent/test/agent_sync_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# frozen_string_literal: true

require "test_helper"

# Mirroring host agent classes into Agent records — the step that lets the
# dashboard run, evaluate and release the agents an app already has in code.
class AgentSyncTest < ActiveSupport::TestCase
class AssembledAgent < ApplicationAgent
generate_with :mock, model: "mock-1"

def self.dashboard_instructions_text = "assembled for the dashboard"

def ask
prompt(message: "hi")
end
end

class InvoiceAgent < ApplicationAgent
generate_with :mock, model: "mock-1"

def ask
prompt(message: "hi")
end
end

setup do
ActionAgent::Agent.delete_all
end

teardown do
ActionAgent::Agent.delete_all
end

test "creates a record carrying the class name, so releases can resolve it" do
result = ActionAgent::AgentSync.call([ InvoiceAgent ], owner: nil_owner)

assert result.success?, result.errors
agent = result.agents.sole
assert_equal "AgentSyncTest::InvoiceAgent", agent.agent_class_name
assert_equal "agent-sync-test-invoice-agent", agent.slug
assert_equal "mock", agent.provider
assert_equal "mock-1", agent.model
end

test "re-running updates in place rather than duplicating" do
ActionAgent::AgentSync.call([ InvoiceAgent ], owner: nil_owner)

assert_difference -> { ActionAgent::Agent.count }, 0 do
ActionAgent::AgentSync.call([ InvoiceAgent ], owner: nil_owner)
end
end

test "the operator's provider and model survive a re-sync" do
agent = ActionAgent::AgentSync.call([ InvoiceAgent ], owner: nil_owner).agents.sole
agent.update!(model: "operator-choice")

ActionAgent::AgentSync.call([ InvoiceAgent ], owner: nil_owner)

# The code owns what an agent is; the operator owns how it runs. A model
# picked in the dashboard must survive the next deploy's sync.
assert_equal "operator-choice", agent.reload.model
end

test "an agent that assembles its own instructions is asked for them" do
result = ActionAgent::AgentSync.call([ AssembledAgent ], owner: nil_owner)

# A record agent's prompt comes from a template it shares with siblings,
# filled from assigns it computes — the class is the only thing that knows
# how to build it, so the sync asks rather than rendering a template that
# would come back empty.
assert_equal "assembled for the dashboard", result.agents.sole.instructions
end

test "a class that is not an agent is skipped, not raised" do
result = ActionAgent::AgentSync.call([ String ], owner: nil_owner)

assert result.success?, result.errors
assert_empty result.agents
assert_match "not an ActiveAgent::Base subclass", result.skipped.sole.skipped
end

private

# Agent has no owner presence validation; the engine scopes by whatever the
# host configures, and these tests exercise the sync itself.
def nil_owner
ActionAgent::Agent.new
end
end
Loading
Loading