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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -3
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +48 -44
  5. data/app/controllers/action_agent/api/agent_runs_controller.rb +25 -7
  6. data/app/controllers/action_agent/api/agents_controller.rb +76 -48
  7. data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
  8. data/app/controllers/action_agent/api/base_controller.rb +16 -0
  9. data/app/controllers/action_agent/api/evaluations_controller.rb +237 -7
  10. data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
  11. data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
  12. data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
  13. data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
  14. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
  15. data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
  16. data/app/controllers/action_agent/api/templates_controller.rb +25 -21
  17. data/app/controllers/action_agent/api/traces_controller.rb +25 -5
  18. data/app/controllers/action_agent/api/usage_controller.rb +20 -0
  19. data/app/controllers/action_agent/application_controller.rb +25 -2
  20. data/app/controllers/action_agent/dashboard_controller.rb +2 -1
  21. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
  22. data/app/jobs/action_agent/agent_execution_job.rb +40 -20
  23. data/app/jobs/action_agent/application_job.rb +7 -3
  24. data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
  25. data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
  26. data/app/models/action_agent/agent.rb +14 -5
  27. data/app/models/action_agent/agent_template.rb +22 -7
  28. data/app/models/action_agent/evaluation.rb +64 -4
  29. data/app/models/action_agent/evaluation_run.rb +182 -2
  30. data/app/models/action_agent/evaluation_scenario.rb +59 -0
  31. data/app/models/action_agent/evaluation_scenario_result.rb +66 -0
  32. data/app/models/action_agent/recording_action.rb +11 -7
  33. data/app/models/action_agent/sandbox_session.rb +1 -1
  34. data/app/models/action_agent/session_recording.rb +31 -8
  35. data/app/models/action_agent/telemetry_trace.rb +110 -2
  36. data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
  37. data/app/models/concerns/action_agent/ownable.rb +15 -2
  38. data/app/queries/action_agent/metrics_report.rb +498 -0
  39. data/app/services/action_agent/agent_toolbox.rb +4 -4
  40. data/app/services/action_agent/evaluation_tool_resolver.rb +154 -0
  41. data/app/services/action_agent/mcp_catalog.rb +46 -8
  42. data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
  43. data/app/services/action_agent/playwright_mcp_client.rb +6 -6
  44. data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
  45. data/app/services/action_agent/scenario_evaluation_runner.rb +226 -0
  46. data/app/services/action_agent/tool_discovery.rb +22 -8
  47. data/config/routes.rb +25 -2
  48. data/lib/action_agent/engine.rb +101 -19
  49. data/lib/action_agent/version.rb +1 -1
  50. data/lib/action_agent.rb +72 -6
  51. data/lib/generators/action_agent/install_generator.rb +20 -7
  52. data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
  53. data/lib/tasks/action_agent.rake +9 -0
  54. 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 (McpCatalog). An install
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 McpServersController < BaseController
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: [ :show, :launch ]
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: McpCatalog.all,
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 || @catalog_entry,
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 = McpCatalog.find(params[:id])
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
- # Exposes the same aggregates as the gem dashboard's metrics page
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, error
10
- # rate, active agents and per-agent statistics — account-scoped, plus
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 = traces_scope.for_date_range(hours.hours.ago(now), now)
36
- previous = traces_scope.for_date_range((hours * 2).hours.ago(now), hours.hours.ago(now))
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 = current_user_provider_key("anthropic")&.credential
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 owns, plus the shared demo. Ownership is a
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 = owned(SessionRecording).or(SessionRecording.where(name: "lander_demo")).recent
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 = owned(SessionRecording).recent.limit(10)
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: request.user_agent,
142
- ip_hash: Digest::SHA256.hexdigest(request.remote_ip.to_s)[0..16]
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
- continuation = SessionRecording.create!(
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
- value: action.value,
350
- metadata: action.metadata
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 = @template.create_agent_for(
38
- current_user,
39
- name: params[:name] || @template.name
40
- )
41
-
42
- if agent.persisted?
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]).take(MAX_TRACES_PER_REQUEST)
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
- head :unauthorized unless result
42
+ deny_access unless result
43
43
  rescue StandardError => e
44
44
  Rails.logger.error("[ActionAgent] Authentication error: #{e.message}")
45
- head :unauthorized
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
- return if run.cancelled? || run.complete?
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.update!(
21
- output: result[:output],
22
- output_metadata: result[:metadata],
23
- status: :complete,
24
- completed_at: Time.current,
25
- duration_ms: ((Time.current - run.started_at) * 1000).to_i,
26
- input_tokens: result.dig(:usage, :input_tokens),
27
- output_tokens: result.dig(:usage, :output_tokens),
28
- total_tokens: result.dig(:usage, :total_tokens)
29
- )
30
- run.add_log("Execution completed successfully", level: :info)
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.update!(
34
- status: :failed,
35
- completed_at: Time.current,
36
- error_message: e.message,
37
- error_backtrace: e.backtrace&.first(10)&.join("\n")
38
- )
39
- run.add_log("Execution failed: #{e.message}", level: :error)
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