actionagent 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,8 +33,8 @@ module ActionAgent
33
33
  scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
34
34
  scope = scope.where(status: params[:status]) if params[:status].present?
35
35
 
36
- page = (params[:page] || 1).to_i
37
- per_page = (params[:per_page] || 20).to_i
36
+ page = integer_param(:page, default: 1)
37
+ per_page = integer_param(:per_page, default: 20)
38
38
  total = scope.count
39
39
  runs = scope.offset((page - 1) * per_page).limit(per_page)
40
40
 
@@ -132,9 +132,9 @@ module ActionAgent
132
132
  # observed from telemetry have no AgentRun rows at all, so a runs-only
133
133
  # list showed them as empty while their scorecard reported real traffic.
134
134
  def runs
135
- minutes = params[:minutes].presence&.then { |m| m.to_i.clamp(1, 60 * 24 * 90) }
136
- page = (params[:page] || 1).to_i
137
- per_page = (params[:per_page] || 20).to_i
135
+ minutes = integer_param(:minutes)&.clamp(1, 60 * 24 * 90)
136
+ page = integer_param(:page, default: 1)
137
+ per_page = integer_param(:per_page, default: 20)
138
138
 
139
139
  executions = AgentExecutions.new(
140
140
  agents: [ @agent ],
@@ -282,7 +282,7 @@ module ActionAgent
282
282
  # with all-zero metrics beside a card and a runs list reporting real
283
283
  # traffic.
284
284
  def analytics
285
- days = (params[:days] || 30).to_i
285
+ days = integer_param(:days, default: 30)
286
286
  start_date = days.days.ago.beginning_of_day
287
287
 
288
288
  runs = @agent.agent_runs.where("created_at >= ?", start_date)
@@ -532,7 +532,10 @@ module ActionAgent
532
532
  change_summary: version.change_summary,
533
533
  created_by: version.created_by,
534
534
  created_at: version.created_at,
535
- is_latest: version.latest?
535
+ is_latest: version.latest?,
536
+ release: version.release?,
537
+ release_digest: version.release_digest,
538
+ revision: version.revision
536
539
  }
537
540
 
538
541
  if include_diff && version.previous
@@ -5,7 +5,7 @@ module ActionAgent
5
5
  class AnalyticsController < BaseController
6
6
  # GET /api/analytics
7
7
  def index
8
- days = (params[:days] || 30).to_i
8
+ days = integer_param(:days, default: 30)
9
9
  start_date = days.days.ago.beginning_of_day
10
10
 
11
11
  # Table names are interpolated rather than written literally: the
@@ -129,6 +129,26 @@ module ActionAgent
129
129
  render json: { error: "Agent execution is disabled on this dashboard" }, status: :forbidden
130
130
  end
131
131
 
132
+ # An integer query param. A value can arrive as a container
133
+ # (`minutes[]=1&minutes[]=2`, or `page[x]=1`), and neither Array nor
134
+ # ActionController::Parameters responds to `to_i`: reading them
135
+ # directly raised NoMethodError and turned a malformed query into a
136
+ # 500. A multi-valued param means its first value; anything else that
137
+ # is not a scalar falls back to the default.
138
+ def integer_param(name, default: nil)
139
+ raw = params[name]
140
+ raw = raw.first if raw.is_a?(Array)
141
+ return default if raw.blank? || !(raw.is_a?(String) || raw.is_a?(Numeric))
142
+
143
+ raw.to_s.to_i
144
+ end
145
+
146
+ # integer_param, then clamped into [min, max]. Non-numeric input becomes
147
+ # 0 and is then clamped up to `min`.
148
+ def clamped_param(name, default:, min:, max:)
149
+ integer_param(name, default: default).clamp(min, max)
150
+ end
151
+
132
152
  def not_found
133
153
  render json: { error: "Record not found" }, status: :not_found
134
154
  end
@@ -16,7 +16,7 @@ module ActionAgent
16
16
 
17
17
  # GET /api/interactions
18
18
  def index
19
- limit = params.fetch(:limit, DEFAULT_LIMIT).to_i.clamp(1, 200)
19
+ limit = clamped_param(:limit, default: DEFAULT_LIMIT, min: 1, max: 200)
20
20
 
21
21
  contexts = interactions_scope
22
22
  .includes(:contextable)
@@ -140,8 +140,8 @@ module ActionAgent
140
140
  def window_minutes
141
141
  return @window_minutes if defined?(@window_minutes)
142
142
 
143
- raw = params[:minutes].presence
144
- @window_minutes = raw ? raw.to_i.clamp(1, MAX_WINDOW_MINUTES) : nil
143
+ raw = integer_param(:minutes)
144
+ @window_minutes = raw ? raw.clamp(1, MAX_WINDOW_MINUTES) : nil
145
145
  end
146
146
 
147
147
  def interactions_scope
@@ -18,11 +18,16 @@ module ActionAgent
18
18
  # POST /api/sandboxes/compare
19
19
  # Run multiple providers in a single sandbox using parallel generation jobs
20
20
  def compare
21
- providers = params[:providers] || %w[anthropic openai ollama]
21
+ providers = params[:providers].nil? ? %w[anthropic openai ollama] : params[:providers]
22
22
  task = params[:task]
23
23
  sandbox_id = params[:sandbox_id]
24
24
 
25
25
  return render json: { error: "Task required" }, status: :bad_request unless task.present?
26
+ # A bare string or a nested object is a malformed request, not a list
27
+ # of one provider: reading it as a list raised NoMethodError.
28
+ unless providers.is_a?(Array) && providers.all? { |name| name.is_a?(String) }
29
+ return render json: { error: "providers must be a list of provider names" }, status: :bad_request
30
+ end
26
31
  return render json: { error: "At least 2 providers required" }, status: :bad_request if providers.size < 2
27
32
 
28
33
  # Validate providers
@@ -13,6 +13,12 @@ module ActionAgent
13
13
 
14
14
  before_action :set_recording, only: [ :show, :actions, :snapshot, :export, :handoff ]
15
15
 
16
+ # Browser state that must never leave the server in a read response:
17
+ # the handoff state a recording carries is a copy of the visitor's
18
+ # cookies and web storage. Only #handoff returns it, to the owner, when
19
+ # they continue the session.
20
+ SENSITIVE_STATE_KEYS = %w[cookies session_storage local_storage].freeze
21
+
16
22
  # GET /api/session_recordings
17
23
  # List recordings with optional filters
18
24
  def index
@@ -36,8 +42,8 @@ module ActionAgent
36
42
  end
37
43
 
38
44
  # Pagination
39
- page = (params[:page] || 1).to_i
40
- per_page = [ (params[:per_page] || 20).to_i, 100 ].min
45
+ page = integer_param(:page, default: 1)
46
+ per_page = [ integer_param(:per_page, default: 20), 100 ].min
41
47
  offset = (page - 1) * per_page
42
48
 
43
49
  total = recordings.count
@@ -79,10 +85,10 @@ module ActionAgent
79
85
 
80
86
  # Support pagination for large recordings
81
87
  if params[:after_sequence].present?
82
- actions = actions.where("sequence > ?", params[:after_sequence].to_i)
88
+ actions = actions.where("sequence > ?", integer_param(:after_sequence, default: 0))
83
89
  end
84
90
 
85
- limit = [ params[:limit]&.to_i || 100, 500 ].min
91
+ limit = [ integer_param(:limit, default: 100), 500 ].min
86
92
  actions = actions.limit(limit)
87
93
 
88
94
  render json: {
@@ -332,7 +338,7 @@ module ActionAgent
332
338
  created_at: recording.created_at.iso8601,
333
339
  updated_at: recording.updated_at.iso8601,
334
340
  timeline: recording.timeline,
335
- handoff_state: recording.metadata["handoff_state"],
341
+ handoff_state: safe_handoff_state(recording.metadata["handoff_state"]),
336
342
  agent: recording.agent_run&.agent&.slice(:id, :name),
337
343
  sandbox_session: recording.sandbox_session&.summary
338
344
  }
@@ -343,9 +349,20 @@ module ActionAgent
343
349
  action&.screenshot_url(expires_in: 1.hour)
344
350
  end
345
351
 
352
+ # Strips the browser state at the top level and inside handoff_state,
353
+ # which the model stores nested (a recording's metadata carries the
354
+ # handoff as one key), so a show response never ships a session cookie.
346
355
  def safe_metadata(metadata)
347
- # Remove sensitive data from metadata
348
- metadata.except("cookies", "session_storage", "local_storage")
356
+ safe = (metadata || {}).except(*SENSITIVE_STATE_KEYS)
357
+ return safe unless safe["handoff_state"].is_a?(Hash)
358
+
359
+ safe.merge("handoff_state" => safe_handoff_state(safe["handoff_state"]))
360
+ end
361
+
362
+ def safe_handoff_state(handoff_state)
363
+ return handoff_state unless handoff_state.is_a?(Hash)
364
+
365
+ handoff_state.except(*SENSITIVE_STATE_KEYS)
349
366
  end
350
367
 
351
368
  def generate_visitor_id
@@ -219,6 +219,46 @@ module ActionAgent
219
219
  agent_versions.order(version_number: :desc).first
220
220
  end
221
221
 
222
+ # The most recent version cut from the agent's code, if any.
223
+ # @return [AgentVersion, nil]
224
+ def latest_release
225
+ agent_versions.releases.order(version_number: :desc).first
226
+ end
227
+
228
+ # Cuts a version for a release of the agent's code, identified by the
229
+ # digest ActiveAgent::Release computes from what the model is given.
230
+ # Returns the existing version when the latest release already carries
231
+ # this digest — a redeploy of an unchanged agent is not a new version —
232
+ # so it is safe to call on every deploy.
233
+ #
234
+ # The version's snapshot is the dashboard configuration plus the release
235
+ # manifest under "release", so the Versions tab can diff two releases the
236
+ # same way it diffs two dashboard edits.
237
+ #
238
+ # @param digest [String] ActiveAgent::Release digest of the host class
239
+ # @param manifest [Hash, nil] the class's release manifest
240
+ # @param revision [String, nil] the deploy (git SHA, release label)
241
+ # @param released_by [String, nil]
242
+ # @return [AgentVersion]
243
+ def record_release!(digest:, manifest: nil, revision: nil, released_by: nil)
244
+ current = latest_release
245
+ if current && current.release_digest == digest
246
+ update_columns(release_digest: digest) if release_digest != digest
247
+ return current
248
+ end
249
+
250
+ version = agent_versions.create!(
251
+ version_number: (latest_version&.version_number || 0) + 1,
252
+ change_summary: release_summary(digest, revision, current&.configuration_snapshot&.dig("release"), manifest),
253
+ configuration_snapshot: configuration_snapshot.merge("release" => manifest || {}),
254
+ release_digest: digest,
255
+ revision: revision,
256
+ created_by: released_by || "release"
257
+ )
258
+ update_columns(release_digest: digest)
259
+ version
260
+ end
261
+
222
262
  # Maps each historical instructions digest to the first version that
223
263
  # introduced it ("v3"), so run cohorts can label instruction changes with
224
264
  # real agent versions instead of raw hashes.
@@ -396,6 +436,18 @@ module ActionAgent
396
436
  end
397
437
  end
398
438
 
439
+ # "Release 1a2b3c4d5e6f · abc1234: templates, tools" — the digest, the
440
+ # deploy, and which parts of the manifest moved since the last release.
441
+ def release_summary(digest, revision, previous_manifest, manifest)
442
+ label = [ "Release #{digest}", revision.presence ].compact.join(" · ")
443
+ return "#{label}: first release" if previous_manifest.blank? || manifest.blank?
444
+
445
+ changed = (previous_manifest.keys | manifest.stringify_keys.keys).select do |key|
446
+ previous_manifest[key] != manifest.stringify_keys[key]
447
+ end
448
+ changed.any? ? "#{label}: #{changed.sort.join(', ')}" : label
449
+ end
450
+
399
451
  def create_initial_version
400
452
  agent_versions.create!(
401
453
  version_number: 1,
@@ -3,6 +3,10 @@
3
3
  module ActionAgent
4
4
  class AgentRun < ApplicationRecord
5
5
  belongs_to :agent
6
+ # The version of the agent this run executed under — the latest at the
7
+ # time, since a run is against the agent as it is.
8
+ belongs_to :agent_version, optional: true
9
+ before_create { self.agent_version_id ||= agent&.latest_version&.id }
6
10
 
7
11
  # Raised when a caller hands a run files to attach in a host app that
8
12
  # has nowhere to keep them.
@@ -9,6 +9,9 @@ module ActionAgent
9
9
 
10
10
  # Scopes
11
11
  scope :recent, -> { order(version_number: :desc) }
12
+ # Versions cut from the agent's code on deploy, as opposed to edits made
13
+ # in the dashboard.
14
+ scope :releases, -> { where.not(release_digest: [ nil, "" ]) }
12
15
  scope :by_version, ->(num) { where(version_number: num) }
13
16
 
14
17
  # Compare two versions
@@ -36,6 +39,13 @@ module ActionAgent
36
39
  end
37
40
 
38
41
  # Check if this is the latest version
42
+ # Whether this version was cut from the agent's code (it carries the
43
+ # release digest) rather than from a dashboard edit.
44
+ # @return [Boolean]
45
+ def release?
46
+ release_digest.present?
47
+ end
48
+
39
49
  def latest?
40
50
  agent.latest_version&.id == id
41
51
  end
@@ -35,6 +35,17 @@ module ActionAgent
35
35
 
36
36
  scope :recent, -> { order(updated_at: :desc) }
37
37
 
38
+ # MySQL cannot give a JSON column a default, so a row inserted there
39
+ # without `criteria` or `config` reads back nil. Both readers answer with
40
+ # the empty value the column default supplies on other databases.
41
+ def criteria
42
+ super || []
43
+ end
44
+
45
+ def config
46
+ super || {}
47
+ end
48
+
38
49
  def latest_run
39
50
  evaluation_runs.order(created_at: :desc).first
40
51
  end
@@ -8,6 +8,10 @@ module ActionAgent
8
8
  # See #average_score, which is what has to tolerate both shapes.
9
9
  class EvaluationRun < ApplicationRecord
10
10
  belongs_to :evaluation
11
+ # The version of the evaluated agent this run scored, so a pass rate is
12
+ # a statement about a release rather than about "the agent".
13
+ belongs_to :agent_version, optional: true
14
+ before_create { self.agent_version_id ||= evaluation&.agent&.latest_version&.id }
11
15
  has_many :scenario_results, class_name: "EvaluationScenarioResult", dependent: :destroy
12
16
 
13
17
  enum :status, { pending: 0, running: 1, complete: 2, failed: 3 }
@@ -43,6 +43,8 @@ module ActionAgent
43
43
  scope :for_date_range, ->(start_date, end_date) { where(timestamp: start_date..end_date) }
44
44
  # The dashboard agent this trace was attributed to on ingest, if any.
45
45
  belongs_to :agent, class_name: "ActionAgent::Agent", optional: true
46
+ # The release of the agent this trace came from (see #attach_agent_version!).
47
+ belongs_to :agent_version, class_name: "ActionAgent::AgentVersion", optional: true
46
48
 
47
49
  scope :for_account, ->(account) { where(account: account) if ActionAgent.multi_tenant? }
48
50
 
@@ -252,7 +254,10 @@ module ActionAgent
252
254
  # dashboard-authored agent by guessing a primary key.
253
255
  attrs[:agent_id] = agent&.id
254
256
 
255
- create!(attrs).tap { |record| AgentRegistrar.call(record) }
257
+ create!(attrs).tap do |record|
258
+ AgentRegistrar.call(record)
259
+ record.attach_agent_version!
260
+ end
256
261
  end
257
262
 
258
263
 
@@ -265,6 +270,28 @@ module ActionAgent
265
270
  tokens.fetch("input", 0).to_i + tokens.fetch("output", 0).to_i + tokens.fetch("thinking", 0).to_i
266
271
  end
267
272
 
273
+ # Pins this trace to the version of its agent that produced it. The
274
+ # instrumentation stamps the root span with `agent.version` — the digest
275
+ # ActiveAgent::Release computes from the class — and a release cut on
276
+ # deploy carries the same digest, so the two meet here. A trace from a
277
+ # dashboard run carries no digest and takes the agent's latest version.
278
+ #
279
+ # @return [AgentVersion, nil]
280
+ def attach_agent_version!
281
+ return if agent_version_id.present? || agent_id.blank?
282
+
283
+ digest = root_span&.dig("attributes", "agent.version").presence
284
+ version = if digest
285
+ AgentVersion.find_by(agent_id: agent_id, release_digest: digest)
286
+ else
287
+ AgentVersion.where(agent_id: agent_id).order(version_number: :desc).first
288
+ end
289
+ return unless version
290
+
291
+ update_columns(agent_version_id: version.id)
292
+ version
293
+ end
294
+
268
295
  # Returns the root span of this trace.
269
296
  #
270
297
  # @return [Hash, nil] The root span or nil
@@ -77,8 +77,22 @@ module ActionAgent
77
77
  ActionAgent.multi_tenant? ? @trace.try(:account) : nil
78
78
  end
79
79
 
80
+ # The set an observed agent is deduplicated within.
81
+ #
82
+ # `for_owner(nil)` is `none` — correct for a multi-tenant read, where an
83
+ # unresolved tenant must see nothing. But a single-tenant dashboard has no
84
+ # owner to resolve and legitimately registers with `owner` nil (see
85
+ # #owner_for_trace), and `none` makes every dedupe lookup miss: each trace
86
+ # created another copy of the same agent, and the MAX_OBSERVED_PER_OWNER
87
+ # cap never engaged because the count it reads was always zero. Scoping to
88
+ # the whole table when there is no owner concept is what `for_owner`
89
+ # already does for a model with no owner association.
90
+ def agents_for_owner(owner)
91
+ owner.nil? && !ActionAgent.multi_tenant? ? Agent.all : Agent.for_owner(owner)
92
+ end
93
+
80
94
  def find_or_create_agent(owner)
81
- agents = Agent.for_owner(owner)
95
+ agents = agents_for_owner(owner)
82
96
  existing = agents.find_by(
83
97
  service_name: @trace.service_name,
84
98
  agent_class_name: agent_class,
@@ -86,6 +100,14 @@ module ActionAgent
86
100
  )
87
101
  return existing if existing
88
102
 
103
+ # A host that mirrors its agent classes into the dashboard (a sync, a
104
+ # release) names the class on the record and nothing else: that record
105
+ # stands for every action of the class, so a trace from the class is
106
+ # its trace — not an observed twin's. Observed records are per action
107
+ # and are only ever matched on all three keys above.
108
+ mirrored = agents.where.not(status: "observed").find_by(agent_class_name: agent_class)
109
+ return mirrored if mirrored
110
+
89
111
  return if agents.observed_agents.count >= MAX_OBSERVED_PER_OWNER
90
112
 
91
113
  create_observed_agent(owner)
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Cuts a version of every dashboard agent that mirrors a host class, from
5
+ # that class's release digest — what a deploy runs so the dashboard's
6
+ # versions line up with the code that shipped.
7
+ #
8
+ # The host owns the mirror: whatever syncs its ActiveAgent classes into
9
+ # Agent records sets `agent_class_name`, and this reads it back. A record
10
+ # whose class no longer resolves, or whose class predates
11
+ # ActiveAgent::Release, is reported and skipped rather than failed.
12
+ #
13
+ # Idempotent: a redeploy of an unchanged agent cuts nothing, so running it
14
+ # on every deploy is the intended use (`rake action_agent:agents:release`).
15
+ class AgentRelease
16
+ Row = Struct.new(:agent, :version, :cut, :skipped, keyword_init: true)
17
+ Result = Struct.new(:rows, :revision, keyword_init: true) do
18
+ def cut = rows.select(&:cut)
19
+ def skipped = rows.select(&:skipped)
20
+ end
21
+
22
+ # @param revision [String, nil] the deploy (git SHA, release label);
23
+ # ActiveAgent::Release.revision when nil
24
+ # @param agents [ActiveRecord::Relation] the records to release; every
25
+ # record naming a host class by default
26
+ # @param released_by [String, nil] recorded on each version cut
27
+ def self.call(revision: nil, agents: nil, released_by: nil)
28
+ new(revision: revision, agents: agents, released_by: released_by).call
29
+ end
30
+
31
+ def initialize(revision: nil, agents: nil, released_by: nil)
32
+ @revision = revision.presence || ActiveAgent::Release.revision
33
+ @agents = agents || Agent.where.not(agent_class_name: [ nil, "" ])
34
+ @released_by = released_by
35
+ end
36
+
37
+ # @return [Result]
38
+ def call
39
+ rows = @agents.order(:name).map { |agent| release(agent) }
40
+ Result.new(rows: rows, revision: @revision)
41
+ end
42
+
43
+ private
44
+
45
+ def release(agent)
46
+ klass = agent.agent_class_name.to_s.safe_constantize
47
+ unless klass.respond_to?(:release_digest)
48
+ return Row.new(agent: agent, version: nil, cut: false, skipped: "#{agent.agent_class_name} does not resolve to a releasable class")
49
+ end
50
+
51
+ before = agent.latest_version&.id
52
+ version = agent.record_release!(
53
+ digest: klass.release_digest,
54
+ manifest: klass.release_manifest,
55
+ revision: @revision,
56
+ released_by: @released_by
57
+ )
58
+ Row.new(agent: agent, version: version, cut: version.id != before, skipped: nil)
59
+ end
60
+ end
61
+ end
@@ -8,11 +8,19 @@ module ActionAgent
8
8
  # Three groups, one row vocabulary — the same one the Tools and MCP
9
9
  # Services pages use, so a roster reads the way the observability views do:
10
10
  #
11
- # * **Agent-defined** — tools discovered from the rosters this agent's own
12
- # generations offered (ToolDiscovery's +agent+ origin). Schema-derived:
13
- # the agent class declares them, so they are reported here rather than
14
- # selected. Read-only for the same reason MCP rows are — a checkbox that
15
- # cannot add or remove the tool is a control that changes nothing.
11
+ # * **Agent-defined** — the tools this agent's own generations offered
12
+ # (ToolDiscovery's +agent+ origin), plus every schema tool the host
13
+ # declares, on or off. Two kinds share the group. A *schema tool* — one
14
+ # a host ActiveAgent::SchemaTools class generates is offered only while
15
+ # +agent.tools+ names it: that is the reading AgentToolbox takes at
16
+ # generation time, and the evaluation runner, dashboard runs and the MCP
17
+ # facade all follow it. So it is selected here, and switchable, and every
18
+ # one has a row because any agent may enable any of them
19
+ # (Agent.available_tools) and a tool switched off has to keep its row to
20
+ # be switched back on. A tool the agent class declares *in code* is
21
+ # offered by the class itself; the dashboard reports it and cannot switch
22
+ # it, so its row is read-only — a checkbox that cannot add or remove the
23
+ # tool is a control that changes nothing.
16
24
  # * **Dashboard** — Agent::AVAILABLE_TOOLS, the capabilities the builder
17
25
  # offers every agent. These are the roster: +agent.tools+ is what
18
26
  # AgentToolbox turns into function schemas at generation time.
@@ -20,8 +28,9 @@ module ActionAgent
20
28
  # agent enables, which is where they are edited.
21
29
  #
22
30
  # Enablement reads the agent's own configuration: +tools+ for the dashboard
23
- # capabilities, +mcp_servers+ for services and their per-server allow-lists
24
- # (an entry with no +tools+ key offers everything the server serves).
31
+ # capabilities and the schema tools, +mcp_servers+ for services and their
32
+ # per-server allow-lists (an entry with no +tools+ key offers everything
33
+ # the server serves).
25
34
  class AgentToolRoster
26
35
  AGENT_DEFINED = "agent_defined"
27
36
  DASHBOARD = "dashboard"
@@ -158,23 +167,40 @@ module ActionAgent
158
167
  agent_defined_rows + dashboard_rows
159
168
  end
160
169
 
161
- # Schema-derived tools: whatever this agent's generations offered that
162
- # is neither an MCP tool nor one of the dashboard's own.
170
+ # Agent-defined tools: whatever this agent's generations offered that is
171
+ # neither an MCP tool nor one of the dashboard's own, plus every schema
172
+ # tool the host declares, whether or not the window saw it.
163
173
  def agent_defined_rows
164
- detected
174
+ observed = detected
165
175
  .select { |tool| tool[:origin] == ToolDiscovery::ORIGIN_AGENT }
166
176
  .reject { |tool| dashboard_function_names.include?(tool[:name]) }
167
- .map do |tool|
168
- usage_row(tool).merge(
169
- key: tool[:name],
170
- name: tool[:name],
171
- source: AGENT_DEFINED,
172
- description: tool[:description],
173
- # The agent class declares these; the dashboard reports them.
174
- enabled: true,
175
- editable: false
176
- )
177
- end
177
+ .index_by { |tool| tool[:name] }
178
+
179
+ (observed.keys + ActionAgent.schema_tool_names).uniq.map do |name|
180
+ tool = observed[name]
181
+ schema = schema_tool?(name)
182
+
183
+ usage_row(tool).merge(
184
+ key: name,
185
+ name: name,
186
+ source: AGENT_DEFINED,
187
+ description: tool&.dig(:description) || schema_tool_description(name),
188
+ # A schema tool is offered only while the roster names it — the
189
+ # window may still show it being called, but that is history. A
190
+ # tool the agent class declares in code is offered by the class.
191
+ enabled: schema ? saved_tools.include?(name) : true,
192
+ editable: schema
193
+ )
194
+ end
195
+ end
196
+
197
+ def schema_tool?(name)
198
+ @schema_tool ||= Hash.new { |cache, key| cache[key] = ActionAgent.schema_tool_class_for(key).present? }
199
+ @schema_tool[name]
200
+ end
201
+
202
+ def schema_tool_description(name)
203
+ AgentToolbox.schema_tool_definitions(name).first&.dig(:description)
178
204
  end
179
205
 
180
206
  def dashboard_rows
@@ -468,15 +468,17 @@ module ActionAgent
468
468
  end
469
469
 
470
470
  def judge_class
471
- provider = judge_provider
472
- model = @evaluation.judge_model.presence
473
- options = {}
474
- options[:model] = model if model
475
- options.merge!(owner_provider_options(provider))
476
-
477
- @judge_class ||= Class.new(ActiveAgent::Base) do
478
- define_singleton_method(:name) { "EvaluationJudgeAgent" }
479
- generate_with provider, **options
471
+ @judge_class ||= begin
472
+ provider = judge_provider
473
+ model = @evaluation.judge_model.presence
474
+ options = {}
475
+ options[:model] = model if model
476
+ options.merge!(owner_provider_options(provider))
477
+
478
+ Class.new(ActiveAgent::Base) do
479
+ define_singleton_method(:name) { "EvaluationJudgeAgent" }
480
+ generate_with provider, **options
481
+ end
480
482
  end
481
483
  end
482
484
 
data/config/routes.rb CHANGED
@@ -161,6 +161,9 @@ ActionAgent::Engine.routes.draw do
161
161
  # dashboard API key rather than a session, so it sits outside the api
162
162
  # namespace's session-authenticated controllers.
163
163
  post "mcp", to: "api/mcp#create"
164
+ match "mcp", to: ->(_env) { [ 405, { "Allow" => "POST" }, [] ] },
165
+ via: [ :get, :delete ],
166
+ constraints: ->(request) { request.delete? || !request.format.html? }
164
167
 
165
168
  # MCP Streamable HTTP (2025-03-26): a client MAY open the server-to-client
166
169
  # SSE stream with GET, and ends a session with DELETE. This facade offers
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionAgent
4
- VERSION = "1.6.0"
4
+ VERSION = "1.6.2"
5
5
  end
@@ -57,6 +57,12 @@ module ActionAgent
57
57
  )
58
58
  end
59
59
 
60
+ # Agent releases arrived after both tables shipped; the migration guards
61
+ # every column, so it is emitted for any install that lacks it.
62
+ unless existing_migration?("add_agent_releases")
63
+ migration_template("add_agent_releases.rb.erb", "db/migrate/add_agent_releases.rb")
64
+ end
65
+
60
66
  # The rest of the dashboard — agents, runs, versions, conversations,
61
67
  # evaluations, sandboxes, recordings, keys. Skippable for an app that
62
68
  # only wants to be a trace sink.
@@ -31,6 +31,14 @@ ActionAgent.configure do |config|
31
31
  # config.authentication_method = nil
32
32
  #
33
33
  config.authentication_method = nil
34
+ #
35
+ # Where a browser is sent when it asks for a dashboard page without a valid
36
+ # session; unset, it gets a minimal session-expired page. API clients get a
37
+ # bare 401 either way. The sign-out path is what the header's "Sign out"
38
+ # item POSTs to (with _method=delete); unset, the item is not shown.
39
+ #
40
+ # config.sign_in_path = "/sign_in"
41
+ # config.sign_out_path = "/sign_out"
34
42
 
35
43
  # ==========================================================================
36
44
  # Ingest API authentication