actionagent 1.2.2 → 1.3.0
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/README.md +14 -3
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +48 -44
- data/app/controllers/action_agent/api/agent_runs_controller.rb +25 -7
- data/app/controllers/action_agent/api/agents_controller.rb +76 -48
- data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
- data/app/controllers/action_agent/api/base_controller.rb +16 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +237 -7
- data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
- data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
- data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
- data/app/controllers/action_agent/api/templates_controller.rb +25 -21
- data/app/controllers/action_agent/api/traces_controller.rb +25 -5
- data/app/controllers/action_agent/api/usage_controller.rb +20 -0
- data/app/controllers/action_agent/application_controller.rb +25 -2
- data/app/controllers/action_agent/dashboard_controller.rb +2 -1
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
- data/app/jobs/action_agent/agent_execution_job.rb +40 -20
- data/app/jobs/action_agent/application_job.rb +7 -3
- data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
- data/app/models/action_agent/agent.rb +14 -5
- data/app/models/action_agent/agent_template.rb +22 -7
- data/app/models/action_agent/evaluation.rb +64 -4
- data/app/models/action_agent/evaluation_run.rb +182 -2
- data/app/models/action_agent/evaluation_scenario.rb +59 -0
- data/app/models/action_agent/evaluation_scenario_result.rb +66 -0
- data/app/models/action_agent/recording_action.rb +11 -7
- data/app/models/action_agent/sandbox_session.rb +1 -1
- data/app/models/action_agent/session_recording.rb +31 -8
- data/app/models/action_agent/telemetry_trace.rb +110 -2
- data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
- data/app/models/concerns/action_agent/ownable.rb +15 -2
- data/app/queries/action_agent/metrics_report.rb +498 -0
- data/app/services/action_agent/agent_toolbox.rb +4 -4
- data/app/services/action_agent/evaluation_tool_resolver.rb +154 -0
- data/app/services/action_agent/mcp_catalog.rb +46 -8
- data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
- data/app/services/action_agent/playwright_mcp_client.rb +6 -6
- data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +226 -0
- data/app/services/action_agent/tool_discovery.rb +22 -8
- data/config/routes.rb +25 -2
- data/lib/action_agent/engine.rb +101 -19
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +72 -6
- data/lib/generators/action_agent/install_generator.rb +20 -7
- data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
- data/lib/tasks/action_agent.rake +9 -0
- metadata +19 -6
|
@@ -7,16 +7,16 @@ module ActionAgent
|
|
|
7
7
|
#
|
|
8
8
|
# The list is the union of three things: servers detected from telemetry
|
|
9
9
|
# and solid_agent records (ToolDiscovery), servers an agent declares in
|
|
10
|
-
# its configuration, and the default catalog (
|
|
10
|
+
# its configuration, and the default catalog (MCPCatalog). An install
|
|
11
11
|
# therefore sees both what it is already using and what it could turn on.
|
|
12
|
-
class
|
|
12
|
+
class MCPServersController < BaseController
|
|
13
13
|
before_action :require_owner!
|
|
14
14
|
# Launching provisions a sandbox and runs a server in it, so it answers
|
|
15
15
|
# to the same two gates as any other execution: the read-only kill
|
|
16
16
|
# switch, and whatever limits the host app imposes.
|
|
17
17
|
before_action :require_execution_enabled!, only: [ :launch ]
|
|
18
18
|
before_action :enforce_execution_quota!, only: [ :launch ]
|
|
19
|
-
before_action :set_catalog_entry, only: [ :
|
|
19
|
+
before_action :set_catalog_entry, only: [ :launch ]
|
|
20
20
|
|
|
21
21
|
STATUS_LABELS = {
|
|
22
22
|
"active" => "Called in this window",
|
|
@@ -32,8 +32,8 @@ module ActionAgent
|
|
|
32
32
|
servers = finder.servers(tools)
|
|
33
33
|
|
|
34
34
|
render json: {
|
|
35
|
-
servers: servers,
|
|
36
|
-
catalog:
|
|
35
|
+
servers: servers.map { |server| at_mount(server) },
|
|
36
|
+
catalog: MCPCatalog.all.map { |entry| at_mount(entry) },
|
|
37
37
|
summary: summary_for(servers),
|
|
38
38
|
sandboxes: active_sandboxes,
|
|
39
39
|
window_hours: finder.window_hours,
|
|
@@ -45,13 +45,23 @@ module ActionAgent
|
|
|
45
45
|
#
|
|
46
46
|
# One server with the tools detected for it, so the view can expand a
|
|
47
47
|
# row without refetching the whole inventory.
|
|
48
|
+
#
|
|
49
|
+
# The index is a union of detected, declared and catalog servers, and
|
|
50
|
+
# a server it lists must be fetchable individually — including the
|
|
51
|
+
# ones the catalog doesn't describe (listed as known: false). Discovery
|
|
52
|
+
# is consulted first; the catalog is the fallback, and only when
|
|
53
|
+
# neither knows the key is it a 404.
|
|
48
54
|
def show
|
|
49
55
|
finder = discovery
|
|
50
56
|
tools = finder.detected_tools
|
|
51
|
-
server = finder.servers(tools).find { |row| row[:key] == params[:id] }
|
|
57
|
+
server = finder.servers(tools).find { |row| row[:key] == params[:id] } || MCPCatalog.find(params[:id])
|
|
58
|
+
|
|
59
|
+
if server.nil?
|
|
60
|
+
return render json: { error: "Unknown MCP server: #{params[:id]}" }, status: :not_found
|
|
61
|
+
end
|
|
52
62
|
|
|
53
63
|
render json: {
|
|
54
|
-
server: server
|
|
64
|
+
server: at_mount(server),
|
|
55
65
|
tools: tools.select { |tool| tool[:mcp_server] == params[:id] }
|
|
56
66
|
}
|
|
57
67
|
end
|
|
@@ -102,6 +112,16 @@ module ActionAgent
|
|
|
102
112
|
ToolDiscovery.new(traces: owned_traces, agents: owner_agents, hours: window_hours)
|
|
103
113
|
end
|
|
104
114
|
|
|
115
|
+
# The catalog names this dashboard's own MCP endpoint as "<mount>/mcp",
|
|
116
|
+
# relative to wherever the engine is mounted; only a request knows
|
|
117
|
+
# where that is. Substituted here, at the edge, so the view shows the
|
|
118
|
+
# endpoint a client can actually connect to rather than the template.
|
|
119
|
+
def at_mount(row)
|
|
120
|
+
return row unless row.is_a?(Hash) && row[:url].is_a?(String) && row[:url].include?("<mount>")
|
|
121
|
+
|
|
122
|
+
row.merge(url: row[:url].sub("<mount>", request.script_name.to_s))
|
|
123
|
+
end
|
|
124
|
+
|
|
105
125
|
def assign_owner(sandbox, association, record)
|
|
106
126
|
return if record.nil?
|
|
107
127
|
return unless sandbox.respond_to?(:"#{association}=")
|
|
@@ -111,7 +131,7 @@ module ActionAgent
|
|
|
111
131
|
end
|
|
112
132
|
|
|
113
133
|
def set_catalog_entry
|
|
114
|
-
@catalog_entry =
|
|
134
|
+
@catalog_entry = MCPCatalog.find(params[:id])
|
|
115
135
|
render json: { error: "Unknown MCP server: #{params[:id]}" }, status: :not_found if @catalog_entry.nil?
|
|
116
136
|
end
|
|
117
137
|
|
|
@@ -4,17 +4,17 @@ module ActionAgent
|
|
|
4
4
|
module Api
|
|
5
5
|
# Read API for telemetry metrics, backing the dashboard Metrics view.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
7
|
+
# Two generations of keys share the response. The legacy keys expose
|
|
8
|
+
# the same aggregates as the gem dashboard's metrics page
|
|
8
9
|
# (ActionAgent::TracesController#metrics / #calculate_metrics /
|
|
9
|
-
# #agent_statistics): trace counts, token totals, average duration,
|
|
10
|
-
# rate, active agents and per-agent statistics — account-scoped,
|
|
11
|
-
# previous-period deltas for trend indicators.
|
|
10
|
+
# #agent_statistics): trace counts, token totals, average duration,
|
|
11
|
+
# error rate, active agents and per-agent statistics — account-scoped,
|
|
12
|
+
# plus previous-period deltas for trend indicators. The APM keys
|
|
13
|
+
# (series, totals, deltas, rails, markers) come from MetricsReport and
|
|
14
|
+
# drive the service-overview layout; see that class for their shape.
|
|
12
15
|
class MetricsController < BaseController
|
|
13
16
|
before_action :require_owner!
|
|
14
17
|
|
|
15
|
-
DEFAULT_WINDOW_HOURS = 24
|
|
16
|
-
MAX_WINDOW_HOURS = 24 * 30
|
|
17
|
-
|
|
18
18
|
# How to rank the per-agent table. Cost is applied after the grouped
|
|
19
19
|
# query because pricing happens in Ruby (rates vary per model), so all
|
|
20
20
|
# four are ordered in one place rather than half in SQL.
|
|
@@ -28,12 +28,34 @@ module ActionAgent
|
|
|
28
28
|
DEFAULT_AGENT_SORT = "popular"
|
|
29
29
|
|
|
30
30
|
# GET /api/metrics
|
|
31
|
+
#
|
|
32
|
+
# Params:
|
|
33
|
+
# range "1h" | "24h" (default) | "7d" — the APM window and its
|
|
34
|
+
# bucket size (MetricsReport::RANGES). A named range also
|
|
35
|
+
# sets the legacy window (1, 24 or 168 hours) so both halves
|
|
36
|
+
# of the response describe the same period.
|
|
37
|
+
# hours the legacy window; without `range` it makes a "custom"
|
|
38
|
+
# range bucketed to about 96 points. Ignored when `range` is
|
|
39
|
+
# a known value.
|
|
40
|
+
# agent an agent_class. When given, EVERY key is scoped to that
|
|
41
|
+
# agent — the legacy summary, hourly buckets and per-agent
|
|
42
|
+
# table included — so the page never shows a filtered chart
|
|
43
|
+
# next to an unfiltered tile.
|
|
44
|
+
# sort ranks the legacy per-agent table (AGENT_SORTS).
|
|
31
45
|
def show
|
|
32
|
-
hours = params.fetch(:hours, DEFAULT_WINDOW_HOURS).to_i.clamp(1, MAX_WINDOW_HOURS)
|
|
33
46
|
now = Time.current
|
|
47
|
+
report = MetricsReport.new(
|
|
48
|
+
traces: traces_scope,
|
|
49
|
+
agents: owner_agents,
|
|
50
|
+
range: params[:range],
|
|
51
|
+
hours: params[:hours],
|
|
52
|
+
agent: agent_filter,
|
|
53
|
+
now: now
|
|
54
|
+
)
|
|
55
|
+
hours = report.window_hours
|
|
34
56
|
|
|
35
|
-
current =
|
|
36
|
-
previous =
|
|
57
|
+
current = legacy_scope.for_date_range(hours.hours.ago(now), now)
|
|
58
|
+
previous = legacy_scope.for_date_range((hours * 2).hours.ago(now), hours.hours.ago(now))
|
|
37
59
|
|
|
38
60
|
costs = cost_statistics(current)
|
|
39
61
|
priced = agent_statistics(current).map { |row| row.merge(cost: costs[:by_agent][row[:name]] || 0.0) }
|
|
@@ -45,7 +67,7 @@ module ActionAgent
|
|
|
45
67
|
window_hours: hours,
|
|
46
68
|
sorts: AGENT_SORTS,
|
|
47
69
|
sort: agent_sort(params[:sort])
|
|
48
|
-
}
|
|
70
|
+
}.merge(report.to_h)
|
|
49
71
|
end
|
|
50
72
|
|
|
51
73
|
private
|
|
@@ -68,10 +90,21 @@ module ActionAgent
|
|
|
68
90
|
rows.sort_by { |row| [ -row[key].to_f, -row[:requests].to_i ] }
|
|
69
91
|
end
|
|
70
92
|
|
|
93
|
+
def agent_filter
|
|
94
|
+
params[:agent].presence
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Every trace the caller can see — what MetricsReport starts from
|
|
98
|
+
# (it applies the agent filter itself).
|
|
71
99
|
def traces_scope
|
|
72
100
|
ActionAgent.trace_model.for_account(current_account)
|
|
73
101
|
end
|
|
74
102
|
|
|
103
|
+
# The legacy keys' scope: traces_scope narrowed to the filtered agent.
|
|
104
|
+
def legacy_scope
|
|
105
|
+
agent_filter ? traces_scope.where(agent_class: agent_filter) : traces_scope
|
|
106
|
+
end
|
|
107
|
+
|
|
75
108
|
# Same definitions as the gem dashboard's calculate_metrics, with
|
|
76
109
|
# previous-period percentage changes layered on top.
|
|
77
110
|
def summary_for(current, previous)
|
|
@@ -74,7 +74,7 @@ module ActionAgent
|
|
|
74
74
|
# Queries the Anthropic Models API with the account's key (newest first,
|
|
75
75
|
# as returned by the API) so new model releases appear without a deploy.
|
|
76
76
|
def live_anthropic_models
|
|
77
|
-
key =
|
|
77
|
+
key = owner_provider_key("anthropic")&.credential
|
|
78
78
|
return nil if key.blank?
|
|
79
79
|
|
|
80
80
|
data = Rails.cache.fetch("provider_models:anthropic:#{Digest::SHA256.hexdigest(key)}", expires_in: 1.hour) do
|
|
@@ -51,6 +51,12 @@ module ActionAgent
|
|
|
51
51
|
sandbox.update!(status: :running)
|
|
52
52
|
comparison_id = SecureRandom.uuid
|
|
53
53
|
|
|
54
|
+
# One execution per provider. Recorded before the jobs are enqueued,
|
|
55
|
+
# mirroring #run's record-before-enqueue order, so usage is counted
|
|
56
|
+
# even if a later enqueue raises. Without this the quota gate on
|
|
57
|
+
# compare was checked but never advanced: comparisons were free.
|
|
58
|
+
providers.size.times { record_execution_usage }
|
|
59
|
+
|
|
54
60
|
# Spawn a separate generation job for each provider (all in same sandbox)
|
|
55
61
|
runs = providers.map do |provider|
|
|
56
62
|
run_id = SecureRandom.uuid
|
|
@@ -16,10 +16,10 @@ module ActionAgent
|
|
|
16
16
|
# GET /api/session_recordings
|
|
17
17
|
# List recordings with optional filters
|
|
18
18
|
def index
|
|
19
|
-
# Recordings the caller
|
|
20
|
-
# real column rather than a JSON metadata key, so this works on
|
|
19
|
+
# Recordings the caller can reach, plus the shared demo. Ownership is
|
|
20
|
+
# a real column rather than a JSON metadata key, so this works on
|
|
21
21
|
# every adapter.
|
|
22
|
-
recordings =
|
|
22
|
+
recordings = reachable_recordings.or(SessionRecording.where(name: "lander_demo")).recent
|
|
23
23
|
|
|
24
24
|
# Filter by status
|
|
25
25
|
recordings = recordings.where(status: params[:status]) if params[:status].present?
|
|
@@ -57,7 +57,7 @@ module ActionAgent
|
|
|
57
57
|
# GET /api/session_recordings/recent
|
|
58
58
|
# Get recent recordings for the current user
|
|
59
59
|
def recent
|
|
60
|
-
recordings =
|
|
60
|
+
recordings = reachable_recordings.recent.limit(10)
|
|
61
61
|
|
|
62
62
|
render json: {
|
|
63
63
|
recordings: recordings.map { |r| recording_summary(r) }
|
|
@@ -132,14 +132,17 @@ module ActionAgent
|
|
|
132
132
|
recording = SessionRecording.start_user_session!(
|
|
133
133
|
visitor_id: params[:visitor_id] || generate_visitor_id,
|
|
134
134
|
parent_demo_id: params[:parent_demo_id],
|
|
135
|
-
page_url: params[:page_url]
|
|
135
|
+
page_url: params[:page_url],
|
|
136
|
+
owner: current_owner
|
|
136
137
|
)
|
|
137
138
|
|
|
138
|
-
# Set user agent from request
|
|
139
|
+
# Set user agent from request. String keys: the stored metadata is
|
|
140
|
+
# string-keyed, and merging symbols wrote a second "user_agent" that
|
|
141
|
+
# json 3.0 refuses to encode.
|
|
139
142
|
recording.update!(
|
|
140
143
|
metadata: recording.metadata.merge(
|
|
141
|
-
user_agent
|
|
142
|
-
ip_hash
|
|
144
|
+
"user_agent" => request.user_agent,
|
|
145
|
+
"ip_hash" => Digest::SHA256.hexdigest(request.remote_ip.to_s)[0..16]
|
|
143
146
|
)
|
|
144
147
|
)
|
|
145
148
|
|
|
@@ -226,8 +229,10 @@ module ActionAgent
|
|
|
226
229
|
return
|
|
227
230
|
end
|
|
228
231
|
|
|
229
|
-
# Create a new recording for the user's continuation
|
|
230
|
-
|
|
232
|
+
# Create a new recording for the user's continuation. It records the
|
|
233
|
+
# caller's own session, so it is owned by the caller — through the
|
|
234
|
+
# owner column the list reads, not a metadata key it never consults.
|
|
235
|
+
continuation = SessionRecording.new(
|
|
231
236
|
sandbox_session: @recording.sandbox_session,
|
|
232
237
|
agent_run: @recording.agent_run,
|
|
233
238
|
name: "#{@recording.name}_continuation",
|
|
@@ -239,6 +244,8 @@ module ActionAgent
|
|
|
239
244
|
user_id: current_user&.id
|
|
240
245
|
}
|
|
241
246
|
)
|
|
247
|
+
continuation.owner = current_owner || @recording.owner
|
|
248
|
+
continuation.save!
|
|
242
249
|
|
|
243
250
|
render json: {
|
|
244
251
|
handoff_state: handoff_state,
|
|
@@ -275,6 +282,19 @@ module ActionAgent
|
|
|
275
282
|
not_found
|
|
276
283
|
end
|
|
277
284
|
|
|
285
|
+
# What the list shows: recordings the caller owns, plus recordings made
|
|
286
|
+
# inside a sandbox the caller owns — the same reachability
|
|
287
|
+
# can_manage_recording? grants to a direct read, so a recording never
|
|
288
|
+
# opens by id while missing from the list. The second clause is what
|
|
289
|
+
# keeps recordings created before the owner column was written
|
|
290
|
+
# reachable.
|
|
291
|
+
def reachable_recordings
|
|
292
|
+
scope = owned(SessionRecording)
|
|
293
|
+
return scope if SessionRecording.owner_association.nil?
|
|
294
|
+
|
|
295
|
+
scope.or(SessionRecording.where(sandbox_session_id: owned(SandboxSession).select(:id)))
|
|
296
|
+
end
|
|
297
|
+
|
|
278
298
|
def can_manage_recording?(recording)
|
|
279
299
|
# An install with no owner model owns everything it can see.
|
|
280
300
|
return true if SessionRecording.owner_association.nil?
|
|
@@ -346,8 +366,10 @@ module ActionAgent
|
|
|
346
366
|
sequence: action.sequence,
|
|
347
367
|
timestamp_ms: action.timestamp_ms,
|
|
348
368
|
selector: action.selector,
|
|
349
|
-
|
|
350
|
-
|
|
369
|
+
# Redacted like /actions: the export used to ship the cleartext
|
|
370
|
+
# the action list had masked.
|
|
371
|
+
value: action.redacted_value,
|
|
372
|
+
metadata: action.safe_metadata
|
|
351
373
|
}
|
|
352
374
|
end
|
|
353
375
|
}
|
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
module ActionAgent
|
|
4
4
|
module Api
|
|
5
5
|
class TemplatesController < BaseController
|
|
6
|
+
include AgentSerialization
|
|
7
|
+
|
|
6
8
|
# No anonymous exemption: #show looks a template up by bare id, so the
|
|
7
9
|
# exemption served unpublished drafts and private prompt libraries to
|
|
8
10
|
# anyone walking the id space. #index was already limited to public
|
|
9
11
|
# templates; #show was not.
|
|
10
12
|
|
|
13
|
+
before_action :require_owner!, only: [ :use ]
|
|
14
|
+
|
|
11
15
|
# GET /api/templates
|
|
12
16
|
def index
|
|
17
|
+
# The library ships with the engine but nothing ever seeded it, so a
|
|
18
|
+
# fresh install showed "No templates found" behind every Browse
|
|
19
|
+
# Templates button. seed_defaults! is idempotent (find_or_create_by!
|
|
20
|
+
# on slug), so an empty table is seeded on first read; the
|
|
21
|
+
# action_agent:seed_templates task does the same on demand.
|
|
22
|
+
AgentTemplate.seed_defaults! if AgentTemplate.none?
|
|
23
|
+
|
|
13
24
|
@templates = AgentTemplate.public_templates.order(usage_count: :desc)
|
|
14
25
|
|
|
15
26
|
# Filter by category
|
|
@@ -31,16 +42,23 @@ module ActionAgent
|
|
|
31
42
|
end
|
|
32
43
|
|
|
33
44
|
# POST /api/templates/:id/use
|
|
45
|
+
#
|
|
46
|
+
# Built through the engine's ownership layer (owner_agents, as
|
|
47
|
+
# AgentsController#create does) rather than the host user's `agents`
|
|
48
|
+
# association: a single-user install has no user, and the old
|
|
49
|
+
# `current_user.agents.build` raised NoMethodError on nil for every
|
|
50
|
+
# click of "Use This Template".
|
|
34
51
|
def use
|
|
35
52
|
@template = AgentTemplate.find(params[:id])
|
|
53
|
+
agent = @template.build_agent_in(owner_agents, name: params[:name])
|
|
36
54
|
|
|
37
|
-
agent
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
render json: { agent: agent_json(agent) }, status: :created
|
|
55
|
+
if agent.save
|
|
56
|
+
@template.increment!(:usage_count)
|
|
57
|
+
# The detail shape, not a summary: the dashboard opens the new agent
|
|
58
|
+
# in the editor straight from this response, and an editor seeded
|
|
59
|
+
# from a summary saved empty instructions/tools/model_config over
|
|
60
|
+
# the template's real ones.
|
|
61
|
+
render json: { agent: agent_json(agent, include_details: true) }, status: :created
|
|
44
62
|
else
|
|
45
63
|
render json: { errors: agent.errors.full_messages }, status: :unprocessable_entity
|
|
46
64
|
end
|
|
@@ -75,20 +93,6 @@ module ActionAgent
|
|
|
75
93
|
|
|
76
94
|
json
|
|
77
95
|
end
|
|
78
|
-
|
|
79
|
-
def agent_json(agent)
|
|
80
|
-
{
|
|
81
|
-
id: agent.id,
|
|
82
|
-
name: agent.name,
|
|
83
|
-
slug: agent.slug,
|
|
84
|
-
description: agent.description,
|
|
85
|
-
provider: agent.provider,
|
|
86
|
-
model: agent.model,
|
|
87
|
-
status: agent.status,
|
|
88
|
-
preset_type: agent.preset_type,
|
|
89
|
-
appearance: agent.appearance
|
|
90
|
-
}
|
|
91
|
-
end
|
|
92
96
|
end
|
|
93
97
|
end
|
|
94
98
|
end
|
|
@@ -36,14 +36,18 @@ module ActionAgent
|
|
|
36
36
|
class TracesController < ActionController::API
|
|
37
37
|
before_action :authenticate_api_key!, if: -> { ActionAgent.multi_tenant? }
|
|
38
38
|
before_action :authenticate_ingest_key!, unless: -> { ActionAgent.multi_tenant? }
|
|
39
|
-
|
|
40
|
-
# Maximum traces accepted per request (mirrors
|
|
41
|
-
# ProcessTelemetryTracesJob::MAX_TRACES_PER_JOB).
|
|
42
|
-
MAX_TRACES_PER_REQUEST = 100
|
|
39
|
+
before_action :enforce_ingest_quota!
|
|
43
40
|
|
|
44
41
|
# POST <mount>/api/traces (e.g. /activeagents/api/traces)
|
|
42
|
+
#
|
|
43
|
+
# Every trace in the request is accepted. The reporter flushes its
|
|
44
|
+
# whole buffer once it reaches batch_size (which is configurable), so
|
|
45
|
+
# a single POST legitimately carries more than a hundred traces; this
|
|
46
|
+
# used to keep the first hundred and answer 202 for the rest, which
|
|
47
|
+
# were silently gone. ProcessTelemetryTracesJob bounds its own work by
|
|
48
|
+
# slicing and re-enqueueing the remainder.
|
|
45
49
|
def create
|
|
46
|
-
traces = Array(params[:traces])
|
|
50
|
+
traces = Array(params[:traces])
|
|
47
51
|
sdk_info = params[:sdk] || {}
|
|
48
52
|
|
|
49
53
|
return head :accepted if traces.empty?
|
|
@@ -106,6 +110,22 @@ module ActionAgent
|
|
|
106
110
|
render json: { error: "Invalid API key" }, status: :unauthorized
|
|
107
111
|
end
|
|
108
112
|
|
|
113
|
+
# The host app's quota checker, asked with kind :trace_ingest — the
|
|
114
|
+
# counterpart to Api::BaseController#enforce_quota!, which asks with
|
|
115
|
+
# :execution. Denials are 429 here rather than 402: a reporter that is
|
|
116
|
+
# over its ingest allowance should back off, not upgrade mid-flush.
|
|
117
|
+
# Same body shape, so a checker's message or Hash payload reads the
|
|
118
|
+
# same on both.
|
|
119
|
+
def enforce_ingest_quota!
|
|
120
|
+
denial = ActionAgent.quota_denial(@account, :trace_ingest)
|
|
121
|
+
return if denial.blank?
|
|
122
|
+
|
|
123
|
+
body = { error: "Trace ingest limit reached" }
|
|
124
|
+
body = denial.is_a?(Hash) ? body.merge(denial) : body.merge(message: denial)
|
|
125
|
+
|
|
126
|
+
render json: body, status: :too_many_requests
|
|
127
|
+
end
|
|
128
|
+
|
|
109
129
|
# Extracts Bearer token from Authorization header.
|
|
110
130
|
def extract_bearer_token
|
|
111
131
|
auth_header = request.headers["Authorization"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# The plan meter the Organization view and the Run Agents quota banner
|
|
6
|
+
# read. Both were extracted from the platform along with the route they
|
|
7
|
+
# fetch, but the engine never gained the route, so every visit to either
|
|
8
|
+
# view logged a 404.
|
|
9
|
+
#
|
|
10
|
+
# The engine meters nothing itself. A host that tracks usage against a
|
|
11
|
+
# plan answers through ActionAgent.usage_resolver; a bare mount reports
|
|
12
|
+
# unlimited, in the same shape, so the views can hide the meter.
|
|
13
|
+
class UsageController < BaseController
|
|
14
|
+
# GET /api/usage
|
|
15
|
+
def show
|
|
16
|
+
render json: { usage: ActionAgent.usage_for(current_owner) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -39,10 +39,33 @@ module ActionAgent
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
result = ActionAgent.authentication_method.call(self)
|
|
42
|
-
|
|
42
|
+
deny_access unless result
|
|
43
43
|
rescue StandardError => e
|
|
44
44
|
Rails.logger.error("[ActionAgent] Authentication error: #{e.message}")
|
|
45
|
-
|
|
45
|
+
deny_access
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A browser asking for a page is sent to the host's sign-in page when one
|
|
49
|
+
# is configured, and otherwise shown a minimal session-expired page — a
|
|
50
|
+
# bare 401 with no body renders as a browser error screen. API and MCP
|
|
51
|
+
# clients get the bare 401 they expect.
|
|
52
|
+
def deny_access
|
|
53
|
+
return head :unauthorized unless Engine.html_request?(request)
|
|
54
|
+
|
|
55
|
+
if ActionAgent.sign_in_path.present?
|
|
56
|
+
redirect_to ActionAgent.sign_in_path, allow_other_host: false
|
|
57
|
+
else
|
|
58
|
+
render html: <<~HTML.html_safe, status: :unauthorized, layout: false
|
|
59
|
+
<!doctype html>
|
|
60
|
+
<html><head><meta charset="utf-8"><title>Sign in required</title></head>
|
|
61
|
+
<body style="font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; display: grid; place-items: center; min-height: 100vh; margin: 0; color: #0f172a; background: #f8fafc;">
|
|
62
|
+
<div style="text-align: center;">
|
|
63
|
+
<h1 style="font-size: 20px;">Sign in required</h1>
|
|
64
|
+
<p style="color: #475569;">Your session has expired or you are not signed in.<br>Sign in to the host application, then reload this page.</p>
|
|
65
|
+
</div>
|
|
66
|
+
</body></html>
|
|
67
|
+
HTML
|
|
68
|
+
end
|
|
46
69
|
end
|
|
47
70
|
|
|
48
71
|
# Returns the current user from the host application.
|
|
@@ -44,7 +44,8 @@ module ActionAgent
|
|
|
44
44
|
availableTools: Agent::AVAILABLE_TOOLS,
|
|
45
45
|
executionEnabled: ActionAgent.execution_enabled?,
|
|
46
46
|
multiTenant: ActionAgent.multi_tenant?,
|
|
47
|
-
upgradeUrl: ActionAgent.upgrade_url
|
|
47
|
+
upgradeUrl: ActionAgent.upgrade_url,
|
|
48
|
+
signOutPath: ActionAgent.sign_out_path
|
|
48
49
|
}
|
|
49
50
|
end
|
|
50
51
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# The agent JSON the dashboard reads, shared by every endpoint that hands
|
|
6
|
+
# an agent to the React app.
|
|
7
|
+
#
|
|
8
|
+
# Two shapes: the summary the list renders, and the detail (instructions,
|
|
9
|
+
# tools, mcp_servers, model_config, ...) the editor initializes from. Any
|
|
10
|
+
# endpoint that returns an agent the client may go on to *edit* must
|
|
11
|
+
# render the detail shape: AgentEditor seeds its form from whatever it is
|
|
12
|
+
# given, and a summary seeds it with empty instructions/tools/config,
|
|
13
|
+
# which the first Save then persists over the real record.
|
|
14
|
+
module AgentSerialization
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def agent_json(agent, include_details: false)
|
|
20
|
+
json = {
|
|
21
|
+
id: agent.id,
|
|
22
|
+
name: agent.name,
|
|
23
|
+
slug: agent.slug,
|
|
24
|
+
description: agent.description,
|
|
25
|
+
provider: agent.provider,
|
|
26
|
+
model: agent.model,
|
|
27
|
+
status: agent.status,
|
|
28
|
+
preset_type: agent.preset_type,
|
|
29
|
+
appearance: agent.appearance,
|
|
30
|
+
version_count: agent.version_count,
|
|
31
|
+
created_at: agent.created_at,
|
|
32
|
+
updated_at: agent.updated_at
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if include_details
|
|
36
|
+
json.merge!(
|
|
37
|
+
instructions: agent.instructions,
|
|
38
|
+
action_prompts: agent.action_prompts,
|
|
39
|
+
instruction_sets: agent.instruction_sets,
|
|
40
|
+
tools: agent.tools,
|
|
41
|
+
mcp_servers: agent.mcp_servers,
|
|
42
|
+
model_config: agent.model_config,
|
|
43
|
+
response_format: agent.response_format,
|
|
44
|
+
agent_class_name: agent.agent_class_name,
|
|
45
|
+
telemetry_agent_class: agent.telemetry_agent_class
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
json
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -6,7 +6,9 @@ module ActionAgent
|
|
|
6
6
|
|
|
7
7
|
def perform(run_id)
|
|
8
8
|
run = AgentRun.find(run_id)
|
|
9
|
-
|
|
9
|
+
# Anything already finished — complete, failed or cancelled — is never
|
|
10
|
+
# executed again. A retry of a failed run would re-run the generation.
|
|
11
|
+
return if run.finished?
|
|
10
12
|
|
|
11
13
|
run.update!(status: :running, started_at: Time.current)
|
|
12
14
|
run.add_log("Starting execution", level: :info)
|
|
@@ -17,26 +19,29 @@ module ActionAgent
|
|
|
17
19
|
# Build the agent class dynamically based on configuration
|
|
18
20
|
result = execute_agent(agent_record, run)
|
|
19
21
|
|
|
20
|
-
run
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
finish_unless_cancelled(run) do
|
|
23
|
+
run.update!(
|
|
24
|
+
output: result[:output],
|
|
25
|
+
output_metadata: result[:metadata],
|
|
26
|
+
status: :complete,
|
|
27
|
+
completed_at: Time.current,
|
|
28
|
+
duration_ms: ((Time.current - run.started_at) * 1000).to_i,
|
|
29
|
+
input_tokens: result.dig(:usage, :input_tokens),
|
|
30
|
+
output_tokens: result.dig(:usage, :output_tokens),
|
|
31
|
+
total_tokens: result.dig(:usage, :total_tokens)
|
|
32
|
+
)
|
|
33
|
+
run.add_log("Execution completed successfully", level: :info)
|
|
34
|
+
end
|
|
32
35
|
rescue => e
|
|
33
|
-
run
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
finish_unless_cancelled(run) do
|
|
37
|
+
run.update!(
|
|
38
|
+
status: :failed,
|
|
39
|
+
completed_at: Time.current,
|
|
40
|
+
error_message: e.message,
|
|
41
|
+
error_backtrace: e.backtrace&.first(10)&.join("\n")
|
|
42
|
+
)
|
|
43
|
+
run.add_log("Execution failed: #{e.message}", level: :error)
|
|
44
|
+
end
|
|
40
45
|
raise
|
|
41
46
|
ensure
|
|
42
47
|
run.broadcast_update
|
|
@@ -48,5 +53,20 @@ module ActionAgent
|
|
|
48
53
|
def execute_agent(agent_record, run)
|
|
49
54
|
AgentExecutionService.call(agent_record, run)
|
|
50
55
|
end
|
|
56
|
+
|
|
57
|
+
# A cancel that arrived while the service was running must survive it:
|
|
58
|
+
# the run's terminal state is written under a row lock after re-reading
|
|
59
|
+
# the status, so a cancelled run is never flipped back to complete or
|
|
60
|
+
# failed by the job that was still executing it.
|
|
61
|
+
def finish_unless_cancelled(run)
|
|
62
|
+
run.with_lock do
|
|
63
|
+
if run.cancelled?
|
|
64
|
+
run.add_log("Execution finished after cancellation; result discarded", level: :info)
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
yield
|
|
69
|
+
end
|
|
70
|
+
end
|
|
51
71
|
end
|
|
52
72
|
end
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module ActionAgent
|
|
4
4
|
# Base class for all Dashboard engine jobs.
|
|
5
|
+
#
|
|
6
|
+
# No blanket retry_on: the engine's jobs are not idempotent. An agent run
|
|
7
|
+
# that fails after real provider work would be re-executed (and re-billed)
|
|
8
|
+
# on every retry, flipping the run's status failed -> running -> failed
|
|
9
|
+
# and overwriting its timings, after the UI had already stopped polling on
|
|
10
|
+
# the first failure. Jobs that can safely retry declare it themselves, for
|
|
11
|
+
# the specific transient errors they can tolerate.
|
|
5
12
|
class ApplicationJob < ActiveJob::Base
|
|
6
|
-
# Retry failed jobs
|
|
7
|
-
retry_on StandardError, wait: :polynomially_longer, attempts: 3
|
|
8
|
-
|
|
9
13
|
# Discard jobs for records that no longer exist
|
|
10
14
|
discard_on ActiveRecord::RecordNotFound
|
|
11
15
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Executes an evaluation run created by Evaluation#run_later!. The run
|
|
5
|
+
# record already exists (pending), so the dashboard can show it while the
|
|
6
|
+
# job is queued; the runner marks it running, then complete or failed.
|
|
7
|
+
class EvaluationRunJob < ApplicationJob
|
|
8
|
+
queue_as :agents
|
|
9
|
+
|
|
10
|
+
def perform(evaluation_id, run_id, selection = {})
|
|
11
|
+
evaluation = Evaluation.find(evaluation_id)
|
|
12
|
+
run = evaluation.evaluation_runs.find(run_id)
|
|
13
|
+
return unless run.pending?
|
|
14
|
+
|
|
15
|
+
evaluation.run!(run: run, **selection.to_h.symbolize_keys)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|