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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.js +11 -11
- data/app/controllers/action_agent/api/agent_runs_controller.rb +2 -2
- data/app/controllers/action_agent/api/agents_controller.rb +8 -5
- data/app/controllers/action_agent/api/analytics_controller.rb +1 -1
- data/app/controllers/action_agent/api/base_controller.rb +20 -0
- data/app/controllers/action_agent/api/interactions_controller.rb +3 -3
- data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -1
- data/app/controllers/action_agent/api/session_recordings_controller.rb +24 -7
- data/app/models/action_agent/agent.rb +52 -0
- data/app/models/action_agent/agent_run.rb +4 -0
- data/app/models/action_agent/agent_version.rb +10 -0
- data/app/models/action_agent/evaluation.rb +11 -0
- data/app/models/action_agent/evaluation_run.rb +4 -0
- data/app/models/action_agent/telemetry_trace.rb +28 -1
- data/app/services/action_agent/agent_registrar.rb +23 -1
- data/app/services/action_agent/agent_release.rb +61 -0
- data/app/services/action_agent/agent_tool_roster.rb +47 -21
- data/app/services/action_agent/evaluation_runner_service.rb +11 -9
- data/config/routes.rb +3 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/generators/action_agent/install_generator.rb +6 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +8 -0
- data/lib/generators/action_agent/templates/add_agent_releases.rb.erb +50 -0
- data/lib/tasks/action_agent.rake +33 -0
- metadata +8 -3
|
@@ -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 = (
|
|
37
|
-
per_page = (
|
|
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 =
|
|
136
|
-
page = (
|
|
137
|
-
per_page = (
|
|
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 = (
|
|
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 = (
|
|
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 =
|
|
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 =
|
|
144
|
-
@window_minutes = raw ? raw.
|
|
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]
|
|
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 = (
|
|
40
|
-
per_page = [ (
|
|
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 > ?",
|
|
88
|
+
actions = actions.where("sequence > ?", integer_param(:after_sequence, default: 0))
|
|
83
89
|
end
|
|
84
90
|
|
|
85
|
-
limit = [
|
|
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
|
-
|
|
348
|
-
|
|
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
|
|
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 =
|
|
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** —
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
|
24
|
-
# (an entry with no +tools+ key offers everything
|
|
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
|
-
#
|
|
162
|
-
#
|
|
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
|
-
.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
data/lib/action_agent/version.rb
CHANGED
|
@@ -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
|