Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The engine's judge blocks take `ActiveAgent::Evals::Judge`'s `kind:`, so a
scenario run's score, recommendation and verdict calls are metered apart.

### Fixed

- `Agent.prompt(...).generate_later` and `Agent.embed(...).embed_later` run
their job instead of raising `ArgumentError: unknown keywords` in the
worker (#346).
- The agent builder and editor can reach every model a provider serves: the
OpenRouter catalog is no longer cut to its first 100 ids, and the model
field is a type-ahead over the catalog that also takes an unlisted id
(`actionagent`, #427).
- A rejected Create Agent shows its validation errors on the builder — a
summary and a message under each field — instead of leaving the form
silently in place. `POST`/`PATCH /api/agents` 422s carry `field_errors`
beside `errors` (`actionagent`, #426).
- An engine agent is refused a provider whose client gem the host has not
installed (`openai` for OpenAI, Ollama and OpenRouter; `anthropic` for
Anthropic) when the provider is chosen, with a validation error naming
the gem, instead of failing on its first run (`actionagent`, #416).

### Security

- The dashboard's JSON API verifies the CSRF token (`actionagent`, #461). It
authenticates with the host's session cookie but had opted out of forgery
protection. The dashboard now sends the page's token with every mutating
request from one fetch shim; the MCP facade and trace ingest, which
authenticate by bearer token, stay exempt. A rejected request answers
`422` with `code: "invalid_csrf_token"`. Hosts that re-enabled protection
themselves (`ActionAgent::Api::BaseController.protect_from_forgery`) can
drop that line.

## [1.6.4] - 2026-09-22

Releases `activeagent` and `actionagent` 1.6.4 from one tag. A patch on 1.6.3
Expand Down
2 changes: 1 addition & 1 deletion actionagent/app/assets/builds/action_agent.css

Large diffs are not rendered by default.

104 changes: 52 additions & 52 deletions actionagent/app/assets/builds/action_agent.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def create
if @agent.save
render json: { agent: agent_json(@agent, include_details: true) }, status: :created
else
render json: { errors: @agent.errors.full_messages }, status: :unprocessable_entity
render json: agent_errors_json(@agent), status: :unprocessable_entity
end
end

Expand All @@ -99,7 +99,7 @@ def update
if @agent.update(agent_params)
render json: { agent: agent_json(@agent, include_details: true) }
else
render json: { errors: @agent.errors.full_messages }, status: :unprocessable_entity
render json: agent_errors_json(@agent), status: :unprocessable_entity
end
end

Expand Down Expand Up @@ -495,6 +495,12 @@ def set_agent
@agent = owner_agents.find(params[:id])
end

# +errors+ for a form-level summary; +field_errors+ (attribute => full
# messages) so the builder and editor can put each under its field.
def agent_errors_json(agent)
{ errors: agent.errors.full_messages, field_errors: agent.errors.to_hash(true) }
end

def agent_params
permitted = params.require(:agent).permit(
:name, :description, :provider, :model, :instructions,
Expand Down
23 changes: 19 additions & 4 deletions actionagent/app/controllers/action_agent/api/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,33 @@ module Api
# install, a per-user install and a multi-tenant platform all read the
# same controllers.
#
# Note this is not the telemetry ingest endpoint — that authenticates
# with a bearer token and lives in Api::TracesController.
# Because it authenticates with the host's session cookie, it keeps the
# forgery protection ApplicationController turns on: the dashboard sends
# the page's CSRF token with every mutating request (frontend
# utils/apiFetch.mjs). Endpoints that authenticate with a bearer token
# instead — the telemetry ingest endpoint (Api::TracesController) and
# the MCP facade (Api::MCPController) — are exempt.
class BaseController < ActionAgent::ApplicationController
skip_forgery_protection

# Rails 8.2 verifies forgery protection from the browser's Sec-Fetch-Site
# header, renamed the failure to InvalidCrossOriginRequest, and deprecated
# the old name. Rescue whichever names the running Rails defines, so a
# rejected request answers with the dashboard's JSON either way.
# const_defined? does not fire the deprecation the bare constant would.
rescue_from ActionController::InvalidCrossOriginRequest, with: :invalid_authenticity_token
if ActionController.const_defined?(:InvalidAuthenticityToken, false)
rescue_from ActionController::InvalidAuthenticityToken, with: :invalid_authenticity_token
end
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
rescue_from ActionController::ParameterMissing, with: :bad_request
rescue_from ActiveRecord::Encryption::Errors::Configuration, with: :encryption_unconfigured

private

def invalid_authenticity_token
render json: { error: "Refresh the dashboard and try again", code: "invalid_csrf_token" }, status: :unprocessable_entity
end

# API keys and provider credentials are encrypted at rest, which needs
# Active Record Encryption keys. The engine derives fallback keys when
# the host set none (see Engine's action_agent.active_record_encryption
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
module ActionAgent
module Api
class DashboardAssistantController < BaseController
protect_from_forgery with: :exception

before_action :require_assistant_enabled!
before_action :require_owner!
before_action :require_execution_enabled!, only: :create
Expand All @@ -13,15 +11,6 @@ class DashboardAssistantController < BaseController
rescue_from DashboardAssistantService::InvalidInput, with: :invalid_input
rescue_from DashboardAssistantService::ProcessingConsentRequired, with: :processing_consent_required
rescue_from DashboardAssistantService::SetupRequired, with: :setup_required
# Rails 8.2 verifies forgery protection from the browser's Sec-Fetch-Site
# header, renamed the failure to InvalidCrossOriginRequest, and deprecated
# the old name. Rescue whichever names the running Rails defines, so a
# rejected request answers with the dashboard's JSON either way.
# const_defined? does not fire the deprecation the bare constant would.
rescue_from ActionController::InvalidCrossOriginRequest, with: :invalid_authenticity_token
if ActionController.const_defined?(:InvalidAuthenticityToken, false)
rescue_from ActionController::InvalidAuthenticityToken, with: :invalid_authenticity_token
end

def show
render json: DashboardAssistantService.new(owner: current_owner).configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ module Api
# { "type": "http", "url": "https://activeagents.ai/mcp",
# "headers": { "Authorization": "Bearer aa_..." } }
class MCPController < BaseController
# Authenticated by API key rather than by the host app's sessions.
# Authenticated by API key rather than by the host app's sessions, so
# there is no session cookie for a cross-site request to ride on.
allow_unauthenticated_access
skip_forgery_protection
before_action :authenticate_api_key!, except: [ :unsupported ]

PROTOCOL_VERSION = "2025-03-26"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,15 @@ def live_anthropic_models
nil
end

# The whole catalog, never a prefix of it: OpenRouter serves several
# hundred models, and a cap after sorting left everything late in the
# alphabet (openai/*, qwen/*, ...) unselectable. The editor filters it.
def live_openrouter_models
data = Rails.cache.fetch("provider_models:openrouter", expires_in: 1.hour) do
fetch_json(URI.parse("https://openrouter.ai/api/v1/models"))
end
ids = Array(data&.dig("data")).filter_map { |model| model["id"] }
[ ids.sort.first(100), "live" ] if ids.any?
[ ids.sort, "live" ] if ids.any?
rescue StandardError => e
Rails.logger.warn("[ProviderModels] openrouter lookup failed: #{e.message}")
nil
Expand Down
16 changes: 16 additions & 0 deletions actionagent/app/models/action_agent/agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def self.polymorphic_name
validates :provider, presence: true
validates :model, presence: true
validate :validate_action_prompts
validate :provider_client_installed, if: :will_save_change_to_provider?, unless: :observed?

# Status enum
# `observed` agents were discovered from reported telemetry rather than
Expand Down Expand Up @@ -491,6 +492,21 @@ def normalized_action(action)
action && available_actions.include?(action) ? action : nil
end

# Provider client gems are optional dependencies of activeagent (OpenAI,
# Ollama and OpenRouter need `openai`, Anthropic needs `anthropic`), so a
# provider can be picked here that the host never installed. Refuse it
# when it is chosen, naming the gem, rather than on the agent's first
# run. Only the providers the engine offers are checked; the host's
# config/active_agent.yml may point one at another service.
def provider_client_installed
return unless PROVIDERS.include?(provider)

service = ActiveAgent::Base.provider_config_load(provider)[:service] || provider.camelize
ActiveAgent::Base.provider_load(service)
rescue LoadError => e
errors.add(:provider, "#{provider} can't be used yet: #{e.message}")
end

def validate_action_prompts
return if action_prompts.blank?

Expand Down
61 changes: 50 additions & 11 deletions actionagent/frontend/components/dashboard/AgentBuilder.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ import React, { useState, useEffect } from 'react';
import AgentAvatar, { AGENT_PRESETS, INSTRUCTIONS, TOOLS } from '../AgentAvatar';
import { ICONS } from '../../utils/designTokens';
import { FALLBACK_PROVIDER_MODELS, fetchProviderModels } from '../../utils/providerModels';
import ModelPicker from './ModelPicker';

const STEPS = [
{ id: 'basics', label: 'Basics', icon: '1' },
{ id: 'configure', label: 'Configure', icon: '2' },
{ id: 'review', label: 'Review', icon: '3' }
];

// Fields on the Basics step; the slug is derived from the name, so its
// errors are the name's to show.
const BASICS_FIELDS = ['name', 'slug', 'description', 'provider', 'model'];

export default function AgentBuilder({ meta, onSave, onCancel, isLoading, initialDraft = null }) {
const [currentStep, setCurrentStep] = useState(0);
const [providerModels, setProviderModels] = useState(FALLBACK_PROVIDER_MODELS);
Expand Down Expand Up @@ -50,6 +55,9 @@ export default function AgentBuilder({ meta, onSave, onCancel, isLoading, initia
return () => { cancelled = true; };
}, [formData.provider]);

// What the server rejected on the last Create Agent: { errors, fieldErrors }.
const [submitErrors, setSubmitErrors] = useState(null);

const updateField = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
Expand Down Expand Up @@ -82,8 +90,15 @@ export default function AgentBuilder({ meta, onSave, onCancel, isLoading, initia
}
};

const handleSubmit = () => {
onSave(formData);
const handleSubmit = async () => {
setSubmitErrors(null);
const failure = await onSave(formData);
if (!failure) return;

setSubmitErrors(failure);
if (Object.keys(failure.fieldErrors || {}).some(field => BASICS_FIELDS.includes(field))) {
setCurrentStep(0);
}
};

return (
Expand Down Expand Up @@ -120,10 +135,24 @@ export default function AgentBuilder({ meta, onSave, onCancel, isLoading, initia
</div>
</div>

{submitErrors && (
<div role="alert" className="mb-4 p-4 rounded-lg border border-red-300 bg-red-50 text-sm text-red-800">
<p className="font-medium">The agent could not be created:</p>
<ul className="mt-1 list-disc list-inside">
{submitErrors.errors.map(message => <li key={message}>{message}</li>)}
</ul>
</div>
)}

{/* Step Content */}
<div className="bg-white rounded-xl border border-gray-200 p-8">
{currentStep === 0 && (
<BasicsStep formData={formData} updateField={updateField} providerModels={providerModels} />
<BasicsStep
formData={formData}
updateField={updateField}
providerModels={providerModels}
fieldErrors={submitErrors?.fieldErrors || {}}
/>
)}
{currentStep === 1 && (
<ConfigureStep
Expand Down Expand Up @@ -164,8 +193,17 @@ export default function AgentBuilder({ meta, onSave, onCancel, isLoading, initia
);
}

function FieldErrors({ messages }) {
if (!messages.length) return null;
return (
<p className="mt-1 text-sm text-red-600">{messages.join('. ')}</p>
);
}

// Step 1: Basics
function BasicsStep({ formData, updateField, providerModels }) {
function BasicsStep({ formData, updateField, providerModels, fieldErrors }) {
const errorsFor = (...fields) => fields.flatMap(field => fieldErrors[field] || []);

return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Basic Information</h2>
Expand All @@ -181,6 +219,7 @@ function BasicsStep({ formData, updateField, providerModels }) {
placeholder="My Awesome Agent"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
<FieldErrors messages={errorsFor('name', 'slug')} />
</div>

<div>
Expand All @@ -192,6 +231,7 @@ function BasicsStep({ formData, updateField, providerModels }) {
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
<FieldErrors messages={errorsFor('description')} />
</div>

<div className="grid grid-cols-2 gap-4">
Expand All @@ -211,19 +251,18 @@ function BasicsStep({ formData, updateField, providerModels }) {
</option>
))}
</select>
<FieldErrors messages={errorsFor('provider')} />
</div>

<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Model</label>
<select
<ModelPicker
value={formData.model}
onChange={(e) => updateField('model', e.target.value)}
models={providerModels[formData.provider]}
onChange={(model) => updateField('model', model)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
>
{providerModels[formData.provider].map(model => (
<option key={model} value={model}>{model}</option>
))}
</select>
/>
<FieldErrors messages={errorsFor('model')} />
</div>
</div>

Expand Down
15 changes: 5 additions & 10 deletions actionagent/frontend/components/dashboard/AgentEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import AgentAvatar, { AGENT_PRESETS } from '../AgentAvatar';
import { TYPOGRAPHY } from '../../utils/designTokens';
import { FALLBACK_PROVIDER_MODELS, fetchProviderModels } from '../../utils/providerModels';
import ModelPicker from './ModelPicker';
import { useTheme } from '../../contexts/ThemeContext';
import { paletteFor, ACCENT } from '../../utils/dashboardTheme';
import TracesView from './TracesView';
Expand Down Expand Up @@ -503,19 +504,13 @@ function ConfigTab({ formData, updateField, providerModels, colors, darkMode, on
</div>
<div>
<label style={labelStyle(colors)}>Model</label>
<select
<ModelPicker
value={formData.model}
onChange={(e) => updateField('model', e.target.value)}
models={providerModels[formData.provider]}
onChange={(model) => updateField('model', model)}
className="aa-field"
style={{ ...fieldStyle(colors), fontFamily: TYPOGRAPHY.mono }}
>
{(providerModels[formData.provider]?.includes(formData.model)
? providerModels[formData.provider]
: [formData.model, ...(providerModels[formData.provider] || [])]
).filter(Boolean).map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
/>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export default function DashboardAssistant({ session, onSessionChange, onReviewD
try {
const response = await fetch('/api/dashboard_assistant', {
method: 'POST', signal: controller.signal,
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, history, provider, model: model.trim(), allow_provider_processing: true }),
});
const data = await response.json();
Expand Down
4 changes: 0 additions & 4 deletions actionagent/frontend/components/dashboard/EvaluationsView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ import { criterionGroup, modelCount, plural, runLabel, runSpend, samplingFixItem
// is what operating it costs, apart from the judge's, which is the
// evaluation's own.

const csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content;

// Mean-score tone: a score is not a pass ratio, so it keeps the thresholds
// the sampling evaluations have always used.
const scoreTone = (value) => (value >= 0.85 ? 'success' : value >= 0.7 ? 'warning' : 'error');
Expand Down Expand Up @@ -251,7 +249,6 @@ export default function EvaluationsView({ embedded = false, agentId = null }) {
try {
const response = await fetch(`/api/evaluations/${evaluation.id}/run`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken() },
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
Expand All @@ -274,7 +271,6 @@ export default function EvaluationsView({ embedded = false, agentId = null }) {
try {
const response = await fetch(`/api/evaluations/${evaluation.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken() },
});
if (response.ok || response.status === 404) {
setEvaluations((prev) => prev.filter((e) => e.id !== evaluation.id));
Expand Down
Loading
Loading