Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d7eb8c3
Bump actions/setup-node from 6 to 7
dependabot[bot] Jul 29, 2026
32e7e9f
chore(deps): bump actions/upload-artifact from 4 to 7
dependabot[bot] Aug 14, 2026
a073de3
chore(deps): bump softprops/action-gh-release from 2 to 3
dependabot[bot] Aug 14, 2026
70ef33b
fix: reject unsupported MCP transport methods
Ricky-7-Yan Aug 27, 2026
e46d4b2
chore(release): activeagent and actionagent 1.6.0
TonsOfFun Sep 14, 2026
ba782eb
fix(tools,telemetry): range filters that compare, and instrumentation…
TonsOfFun Sep 16, 2026
877ca0f
fix(dashboard): an observed agent is registered once, not once per trace
TonsOfFun Sep 16, 2026
cfda405
chore(release): activeagent and actionagent 1.6.1
TonsOfFun Sep 16, 2026
8a2364b
fix(dashboard): an evaluation created on MySQL can be run
TonsOfFun Sep 16, 2026
53bd9b7
refactor(dashboard): build the judge class inside its memoization
TonsOfFun Sep 16, 2026
f2d64b4
docs(dashboard): document sign_in_path and sign_out_path
TonsOfFun Sep 16, 2026
4f9f08a
Merge remote-tracking branch 'origin/pr/454' into release/1.6.2
TonsOfFun Sep 16, 2026
023912f
Merge remote-tracking branch 'origin/pr/452' into release/1.6.2
TonsOfFun Sep 16, 2026
f0aea20
Merge remote-tracking branch 'origin/pr/453' into release/1.6.2
TonsOfFun Sep 16, 2026
d25f893
Merge remote-tracking branch 'origin/pr/398' into release/1.6.2
TonsOfFun Sep 16, 2026
70fe918
Merge remote-tracking branch 'origin/pr/358' into release/1.6.2
TonsOfFun Sep 16, 2026
eed33bc
Merge remote-tracking branch 'origin/pr/357' into release/1.6.2
TonsOfFun Sep 16, 2026
7900199
Merge remote-tracking branch 'origin/pr/348' into release/1.6.2
TonsOfFun Sep 16, 2026
a34258f
docs(changelog): file the merged entries under Unreleased
TonsOfFun Sep 16, 2026
f7290b0
chore(release): activeagent and actionagent 1.6.2
TonsOfFun Sep 16, 2026
0dc93e5
Coerce container-valued query params and scrub handoff state on read
claude Sep 16, 2026
622def4
Merge remote-tracking branch 'origin/main' into release/1.6.2
TonsOfFun Sep 16, 2026
49a57cb
test(telemetry): restore the global configuration after swapping the …
TonsOfFun Sep 16, 2026
189e3d1
Merge branch 'claude/activeagent-design-artifacts-q596yu' into releas…
claude Sep 16, 2026
8ebff51
docs(changelog): file the dashboard API hardening under 1.6.2
claude Sep 16, 2026
f995cc9
test: load Action Cable's config before any test can stub Rails.env
claude Sep 16, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
fetch-depth: 0

- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
Expand Down Expand Up @@ -103,7 +103,7 @@ jobs:
fetch-depth: 0

- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ jobs:
# actually contain their entry points — a gem missing one installs and
# resolves, then dies on require.
- run: bundle exec rake build_all
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: gems
path: pkg/*.gem
- name: Attach gems to the release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: pkg/*.gem
generate_release_notes: true
Expand Down
148 changes: 134 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.6.2] - 2026-09-16

Releases `activeagent` and `actionagent` 1.6.2 from one tag.

Agents gain releases: a digest of everything the model is given, cut on
deploy and pinned to every trace, run and evaluation run, so a score is a
statement about a specific release and a regression is attributable to the
change that caused it. Around it, five dashboard fixes: an evaluation
created on MySQL can be run, the Tools tab reads the same `agent.tools` the
runner does, a container-valued query parameter is coerced instead of
raising, a recording's detail response no longer carries the visitor's
cookies and web storage, and the MCP endpoint answers an unsupported method with 405
instead of the dashboard page. `sign_in_path` and `sign_out_path` are now
documented.

Upgrading: the install generator emits a new `add_agent_releases` migration
(guarded column by column); run it. Cutting a release is
`rake action_agent:agents:release[REVISION]` in the deploy.

### Added

- **Agents have releases, and every trace, run and evaluation says which one
Expand All @@ -33,6 +52,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
service, class *and* action, so every code-path trace registered an
observed per-action twin beside the synced record and could never be
pinned to its release.

### Fixed

- **An evaluation created on MySQL can be run.** MySQL cannot give a JSON
column a default, so an evaluation saved there without `config` read it
back as `nil`, and `compare_models` raised before the runner did anything
else. `config` and `criteria` now read as the empty value their column
default supplies on other databases. (#417)
- **The Tools tab now says which schema tools an agent is offered, and
lets you change it.** The editor listed every schema tool as enabled and
read-only whatever `agent.tools` held — *"a checkbox that cannot add or
remove the tool is a control that changes nothing"* — while evaluations,
dashboard runs and the MCP facade offered exactly what that column named.
An agent whose roster had been emptied over the API ran a suite with no
tools (1/8, `expected tool not called ×6`) under a tab reading "12
enabled". A schema tool's row now reads the roster and is switchable, and
every schema tool the host declares has a row, off unless the roster names
it — any agent may enable any of them, and a tool switched off has to keep
its row to be switched back on. A tool the agent class declares in code is
still reported rather than selected: the class offers it, and no checkbox
could change that.
- **A container-valued query parameter no longer 500s the dashboard API.**
`page`, `per_page`, `days`, `minutes`, `limit` and `after_sequence` were
read with `to_i`, which neither an Array (`minutes[]=1&minutes[]=2`) nor a
nested object (`page[x]=1`) answers. `Api::BaseController` now coerces
them: a multi-valued parameter means its first value, a nested object falls
back to the default, and the clamps that bounded the number still apply.
`sandboxes#compare` answers a `providers` value that is not a list of
names with a 400 instead of a `NoMethodError`.
- **A session recording's `show` no longer returns the visitor's cookies and
web storage.** Every other read path redacted the handoff state, but the
detail response carried `cookies`, `session_storage` and `local_storage`
unscrubbed, both as its own key and nested inside `metadata`. Both are now
stripped; only `#handoff` returns them, to the recording's owner. (#456)

## [1.6.1] - 2026-09-16

Releases `activeagent` and `actionagent` 1.6.1 from one tag.

A patch for two defects that share a failure mode: each one turns a broken
run into a plausible-looking success rather than an error. A date filter that
matched nothing reported zero instead of raising, and an agent reported that
zero as fact; telemetry that was enabled but never instrumented wrote no
traces while every configuration signal read healthy. Neither surfaced in a
test suite, because neither produces a failure — only a confident wrong
answer and an empty table.

No new public surface and no behaviour change for anything that was already
working, so a patch under semver. Suites that filter on a date column will
report different — correct — numbers after upgrading; read the first run as a
corrected baseline.

### Fixed

- **A range filter on a `SchemaTools` column no longer matches nothing and
reports zero.** `permitted_filters!` validated the column against the
allowlist but passed the value through untouched, so a range hash reached
`where` unrecognized and Rails compiled `where(due_date: {"before" => x})`
to `due_date = NULL` — a predicate that matches no row. The tool returned
`{count: 0}` with no error and the model read it as a truthful empty
answer: "0 overdue tickets" against a database holding four. Equality
filters were unaffected, which is why this went unnoticed. Comparisons are
now built through Arel with the column's own type cast, under the operators
`before`, `after`, `lt`, `lte`, `gt`, `gte`, `on_or_before` and
`on_or_after`; two bounds may be given together to express a window; and an
operator outside that set raises `UnpermittedAttribute` rather than
returning zero, consistent with how an undeclared column is already
rejected. Ranges are offered for date, datetime, time and numeric columns
only — a lexical `>` on a name column answers a question nobody asked.
- **A range filter is now discoverable.** `filter_properties` described a date
column as a bare `{type: "string", format: "date"}`, so the tool surface
could not express "before today" at all and a model asking the question
correctly still had no way to ask it. Comparable columns are now offered as
`anyOf: [scalar, range object]`, with the operator roster in the schema.
- **Telemetry enabled from a host app's initializer now installs
instrumentation.** The railtie prepended `GenerationInstrumentation` only
when `Telemetry.enabled?` was already true as railties ran — before
`config/initializers/*.rb`. An app that configures telemetry in its own
initializer, which is what the documentation shows, was therefore never
instrumented: `enabled?` answered true, `local_storage` was on, the trace
model resolved and the store lambda worked when called directly, and no
generation ever produced a span to store. `configure` now installs as well
when the resulting configuration is enabled; `instrument_telemetry!` is
idempotent, so the railtie path and the configure path cannot
double-prepend and initializer order stops mattering.

## [1.6.0] - 2026-09-14

Releases `activeagent` and `actionagent` 1.6.0 from one tag.

A minor, not a patch. The cycle that began after 1.5.2 gives an agent a
caller — `current_user`, carried from whatever authenticated the call into
every `before_action`, every tool, every delegated sub-agent, every run over
MCP and every evaluation replay — so an authorization gem has something to
decide against. Around it: schema tools defined at runtime rather than only
in a file, a generator that writes the first one, those tools served
directly over MCP, and an evaluation that calls a fabricated answer a fault
instead of grading it as an honest gap. That is new public surface in both
gems, which is a minor under semver even though 1.5.2 shipped a feature as a
patch.

Two notes for upgrades. `tools_succeeded` is now awarded only for a tool the
scenario expected, so a suite that was quietly scoring wrong-tool runs as
partial successes will report lower — read the first run as a corrected
baseline. And `actor:` is now stripped from tool arguments and from
`params[params][actor]`: the caller is a property of the run, set once by
whatever authenticated it, and can no longer be named by the model or by a
client.

The engine's floor on the framework (`activeagent >= 1.4`) is unchanged and
still correct: 1.6.0 satisfies it.

### Added

- **An evaluation replay runs as the evaluation's owner.** The scenario runner
handed `Agent#test_execute` no caller, so every tool a replay called ran
unattributed and a host scope answered empty — the suite graded an agent
Expand Down Expand Up @@ -116,6 +249,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **The judge reads more of a scenario's notes** — 1,500 characters rather
than 300 — because a suite's notes are often its rubric and the "must not"
clause tends to come last. (#433)

### Fixed

- **The caller can no longer be named by the model, or by the client.**
Expand All @@ -139,20 +273,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
hash naming both `provider` and `model` is now rebuilt as it was; a bare
label is still parsed. The dashboard's "re-run" of a saved selection is
the path this fixes.
- **The Tools tab now says which schema tools an agent is offered, and
lets you change it.** The editor listed every schema tool as enabled and
read-only whatever `agent.tools` held — *"a checkbox that cannot add or
remove the tool is a control that changes nothing"* — while evaluations,
dashboard runs and the MCP facade offered exactly what that column named.
An agent whose roster had been emptied over the API ran a suite with no
tools (1/8, `expected tool not called ×6`) under a tab reading "12
enabled". A schema tool's row now reads the roster and is switchable, and
every schema tool the host declares has a row, off unless the roster names
it — any agent may enable any of them, and a tool switched off has to keep
its row to be switched back on. A tool the agent class declares in code is
still reported rather than selected: the class offers it, and no checkbox
could change that.

## [1.5.2] - 2026-09-11

Releases `activeagent` and `actionagent` 1.5.2 from one tag.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ def index
scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
scope = scope.where(status: params[:status]) if params[:status].present?

page = (params[:page] || 1).to_i
per_page = (params[:per_page] || 20).to_i
page = integer_param(:page, default: 1)
per_page = integer_param(:per_page, default: 20)
total = scope.count
runs = scope.offset((page - 1) * per_page).limit(per_page)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ def restore
# observed from telemetry have no AgentRun rows at all, so a runs-only
# list showed them as empty while their scorecard reported real traffic.
def runs
minutes = params[:minutes].presence&.then { |m| m.to_i.clamp(1, 60 * 24 * 90) }
page = (params[:page] || 1).to_i
per_page = (params[:per_page] || 20).to_i
minutes = integer_param(:minutes)&.clamp(1, 60 * 24 * 90)
page = integer_param(:page, default: 1)
per_page = integer_param(:per_page, default: 20)

executions = AgentExecutions.new(
agents: [ @agent ],
Expand Down Expand Up @@ -282,7 +282,7 @@ def tool_roster
# with all-zero metrics beside a card and a runs list reporting real
# traffic.
def analytics
days = (params[:days] || 30).to_i
days = integer_param(:days, default: 30)
start_date = days.days.ago.beginning_of_day

runs = @agent.agent_runs.where("created_at >= ?", start_date)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module Api
class AnalyticsController < BaseController
# GET /api/analytics
def index
days = (params[:days] || 30).to_i
days = integer_param(:days, default: 30)
start_date = days.days.ago.beginning_of_day

# Table names are interpolated rather than written literally: the
Expand Down
20 changes: 20 additions & 0 deletions actionagent/app/controllers/action_agent/api/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ def require_execution_enabled!
render json: { error: "Agent execution is disabled on this dashboard" }, status: :forbidden
end

# An integer query param. A value can arrive as a container
# (`minutes[]=1&minutes[]=2`, or `page[x]=1`), and neither Array nor
# ActionController::Parameters responds to `to_i`: reading them
# directly raised NoMethodError and turned a malformed query into a
# 500. A multi-valued param means its first value; anything else that
# is not a scalar falls back to the default.
def integer_param(name, default: nil)
raw = params[name]
raw = raw.first if raw.is_a?(Array)
return default if raw.blank? || !(raw.is_a?(String) || raw.is_a?(Numeric))

raw.to_s.to_i
end

# integer_param, then clamped into [min, max]. Non-numeric input becomes
# 0 and is then clamped up to `min`.
def clamped_param(name, default:, min:, max:)
integer_param(name, default: default).clamp(min, max)
end

def not_found
render json: { error: "Record not found" }, status: :not_found
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class InteractionsController < BaseController

# GET /api/interactions
def index
limit = params.fetch(:limit, DEFAULT_LIMIT).to_i.clamp(1, 200)
limit = clamped_param(:limit, default: DEFAULT_LIMIT, min: 1, max: 200)

contexts = interactions_scope
.includes(:contextable)
Expand Down Expand Up @@ -140,8 +140,8 @@ def traces_for_agent(agent_id)
def window_minutes
return @window_minutes if defined?(@window_minutes)

raw = params[:minutes].presence
@window_minutes = raw ? raw.to_i.clamp(1, MAX_WINDOW_MINUTES) : nil
raw = integer_param(:minutes)
@window_minutes = raw ? raw.clamp(1, MAX_WINDOW_MINUTES) : nil
end

def interactions_scope
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ class SandboxesController < BaseController
# POST /api/sandboxes/compare
# Run multiple providers in a single sandbox using parallel generation jobs
def compare
providers = params[:providers] || %w[anthropic openai ollama]
providers = params[:providers].nil? ? %w[anthropic openai ollama] : params[:providers]
task = params[:task]
sandbox_id = params[:sandbox_id]

return render json: { error: "Task required" }, status: :bad_request unless task.present?
# A bare string or a nested object is a malformed request, not a list
# of one provider: reading it as a list raised NoMethodError.
unless providers.is_a?(Array) && providers.all? { |name| name.is_a?(String) }
return render json: { error: "providers must be a list of provider names" }, status: :bad_request
end
return render json: { error: "At least 2 providers required" }, status: :bad_request if providers.size < 2

# Validate providers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ class SessionRecordingsController < BaseController

before_action :set_recording, only: [ :show, :actions, :snapshot, :export, :handoff ]

# Browser state that must never leave the server in a read response:
# the handoff state a recording carries is a copy of the visitor's
# cookies and web storage. Only #handoff returns it, to the owner, when
# they continue the session.
SENSITIVE_STATE_KEYS = %w[cookies session_storage local_storage].freeze

# GET /api/session_recordings
# List recordings with optional filters
def index
Expand All @@ -36,8 +42,8 @@ def index
end

# Pagination
page = (params[:page] || 1).to_i
per_page = [ (params[:per_page] || 20).to_i, 100 ].min
page = integer_param(:page, default: 1)
per_page = [ integer_param(:per_page, default: 20), 100 ].min
offset = (page - 1) * per_page

total = recordings.count
Expand Down Expand Up @@ -79,10 +85,10 @@ def actions

# Support pagination for large recordings
if params[:after_sequence].present?
actions = actions.where("sequence > ?", params[:after_sequence].to_i)
actions = actions.where("sequence > ?", integer_param(:after_sequence, default: 0))
end

limit = [ params[:limit]&.to_i || 100, 500 ].min
limit = [ integer_param(:limit, default: 100), 500 ].min
actions = actions.limit(limit)

render json: {
Expand Down Expand Up @@ -332,7 +338,7 @@ def recording_detail(recording)
created_at: recording.created_at.iso8601,
updated_at: recording.updated_at.iso8601,
timeline: recording.timeline,
handoff_state: recording.metadata["handoff_state"],
handoff_state: safe_handoff_state(recording.metadata["handoff_state"]),
agent: recording.agent_run&.agent&.slice(:id, :name),
sandbox_session: recording.sandbox_session&.summary
}
Expand All @@ -343,9 +349,20 @@ def first_screenshot_url(recording)
action&.screenshot_url(expires_in: 1.hour)
end

# Strips the browser state at the top level and inside handoff_state,
# which the model stores nested (a recording's metadata carries the
# handoff as one key), so a show response never ships a session cookie.
def safe_metadata(metadata)
# Remove sensitive data from metadata
metadata.except("cookies", "session_storage", "local_storage")
safe = (metadata || {}).except(*SENSITIVE_STATE_KEYS)
return safe unless safe["handoff_state"].is_a?(Hash)

safe.merge("handoff_state" => safe_handoff_state(safe["handoff_state"]))
end

def safe_handoff_state(handoff_state)
return handoff_state unless handoff_state.is_a?(Hash)

handoff_state.except(*SENSITIVE_STATE_KEYS)
end

def generate_visitor_id
Expand Down
Loading
Loading