actionagent 0.0.0 → 1.2.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +103 -0
  4. data/app/assets/builds/action_agent.css +2 -0
  5. data/app/assets/builds/action_agent.js +163 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
  7. data/app/controllers/action_agent/api/agents_controller.rb +411 -0
  8. data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
  9. data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
  10. data/app/controllers/action_agent/api/base_controller.rb +112 -0
  11. data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
  12. data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
  13. data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
  14. data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
  15. data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
  16. data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
  17. data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
  18. data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
  19. data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
  20. data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
  21. data/app/controllers/action_agent/api/templates_controller.rb +94 -0
  22. data/app/controllers/action_agent/api/tools_controller.rb +58 -0
  23. data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
  24. data/app/controllers/action_agent/api/traces_controller.rb +136 -0
  25. data/app/controllers/action_agent/application_controller.rb +105 -0
  26. data/app/controllers/action_agent/dashboard_controller.rb +104 -0
  27. data/app/controllers/action_agent/traces_controller.rb +121 -0
  28. data/app/jobs/action_agent/agent_execution_job.rb +52 -0
  29. data/app/jobs/action_agent/application_job.rb +12 -0
  30. data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
  31. data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
  32. data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
  33. data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
  34. data/app/jobs/action_agent/trace_retention_job.rb +57 -0
  35. data/app/models/action_agent/agent.rb +343 -0
  36. data/app/models/action_agent/agent_context.rb +129 -0
  37. data/app/models/action_agent/agent_generation.rb +48 -0
  38. data/app/models/action_agent/agent_memory.rb +50 -0
  39. data/app/models/action_agent/agent_memory_entry.rb +14 -0
  40. data/app/models/action_agent/agent_message.rb +43 -0
  41. data/app/models/action_agent/agent_run.rb +151 -0
  42. data/app/models/action_agent/agent_template.rb +182 -0
  43. data/app/models/action_agent/agent_version.rb +48 -0
  44. data/app/models/action_agent/api_key.rb +53 -0
  45. data/app/models/action_agent/application_record.rb +27 -0
  46. data/app/models/action_agent/evaluation.rb +80 -0
  47. data/app/models/action_agent/evaluation_run.rb +20 -0
  48. data/app/models/action_agent/model_pricing.rb +80 -0
  49. data/app/models/action_agent/provider_key.rb +60 -0
  50. data/app/models/action_agent/recording_action.rb +119 -0
  51. data/app/models/action_agent/recording_snapshot.rb +88 -0
  52. data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
  53. data/app/models/action_agent/sandbox_run.rb +45 -0
  54. data/app/models/action_agent/sandbox_session.rb +160 -0
  55. data/app/models/action_agent/session_recording.rb +178 -0
  56. data/app/models/action_agent/telemetry_trace.rb +357 -0
  57. data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
  58. data/app/models/concerns/action_agent/ownable.rb +86 -0
  59. data/app/models/concerns/action_agent/session_recordable.rb +91 -0
  60. data/app/queries/action_agent/agent_executions.rb +201 -0
  61. data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
  62. data/app/serializers/action_agent/interaction_preview.rb +22 -0
  63. data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
  64. data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
  65. data/app/services/action_agent/agent_execution_service.rb +572 -0
  66. data/app/services/action_agent/agent_registrar.rb +197 -0
  67. data/app/services/action_agent/agent_scorecard.rb +191 -0
  68. data/app/services/action_agent/agent_toolbox.rb +504 -0
  69. data/app/services/action_agent/evaluation_runner_service.rb +481 -0
  70. data/app/services/action_agent/mcp_catalog.rb +247 -0
  71. data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
  72. data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
  73. data/app/services/action_agent/playwright_mcp_client.rb +148 -0
  74. data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
  75. data/app/services/action_agent/session_recording_service.rb +228 -0
  76. data/app/services/action_agent/tool_discovery.rb +617 -0
  77. data/app/views/action_agent/dashboard/index.html.erb +5 -0
  78. data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
  79. data/app/views/action_agent/traces/index.html.erb +135 -0
  80. data/app/views/action_agent/traces/metrics.html.erb +145 -0
  81. data/app/views/action_agent/traces/show.html.erb +36 -0
  82. data/app/views/layouts/action_agent/application.html.erb +94 -0
  83. data/app/views/layouts/action_agent/react.html.erb +19 -0
  84. data/config/routes.rb +144 -0
  85. data/lib/action_agent/compatibility.rb +49 -0
  86. data/lib/action_agent/engine.rb +51 -0
  87. data/lib/action_agent/version.rb +5 -0
  88. data/lib/action_agent.rb +388 -0
  89. data/lib/actionagent.rb +6 -0
  90. data/lib/generators/action_agent/install_generator.rb +137 -0
  91. data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
  92. data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
  93. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
  94. data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
  95. metadata +209 -12
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ class AgentRunsController < BaseController
6
+ before_action :set_run, only: [ :show, :cancel ]
7
+
8
+ # GET /api/runs/:id
9
+ def show
10
+ render json: {
11
+ run: run_json(@run),
12
+ messages: interaction_messages(@run),
13
+ agent: {
14
+ id: @run.agent.id,
15
+ name: @run.agent.name,
16
+ slug: @run.agent.slug
17
+ }
18
+ }
19
+ end
20
+
21
+ # POST /api/runs/:id/cancel
22
+ def cancel
23
+ @run.cancel!
24
+ render json: { run: @run.summary }
25
+ end
26
+
27
+ # GET /api/runs
28
+ def index
29
+ # Scoped to the caller's own agents: this listed (and counted) every
30
+ # run in the database regardless of who owned it.
31
+ scope = AgentRun.includes(:agent).where(agent: owner_agents).recent
32
+
33
+ scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
34
+ scope = scope.where(status: params[:status]) if params[:status].present?
35
+
36
+ page = (params[:page] || 1).to_i
37
+ per_page = (params[:per_page] || 20).to_i
38
+ total = scope.count
39
+ runs = scope.offset((page - 1) * per_page).limit(per_page)
40
+
41
+ render json: {
42
+ runs: runs.map { |run| run_json(run, include_agent: true) },
43
+ meta: {
44
+ page: page,
45
+ per_page: per_page,
46
+ total: total
47
+ }
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ def set_run
54
+ @run = AgentRun.where(agent: owner_agents).find(params[:id])
55
+ end
56
+
57
+ # The run's slice of its agent's conversation stream, for the shared
58
+ # InteractionStream UI. User messages carry the run's trace_id in
59
+ # provenance (SolidAgent::HasContext); the slice spans from this run's
60
+ # user message up to the next user message with a different trace_id.
61
+ # Prepended with the system instructions the run executed under
62
+ # (captured in output_metadata; falls back to the agent's current ones).
63
+ def interaction_messages(run)
64
+ return [] if run.trace_id.blank?
65
+
66
+ context = AgentContext.for_agents(Agent.where(id: run.agent_id)).order(created_at: :desc).first
67
+ return [] unless context
68
+
69
+ messages = context.messages.chronological.to_a
70
+ start_index = messages.index do |message|
71
+ message.role == "user" && message.provenance&.dig("trace_id") == run.trace_id
72
+ end
73
+ return [] unless start_index
74
+
75
+ slice = [ messages[start_index] ]
76
+ messages[(start_index + 1)..].each do |message|
77
+ trace = message.provenance&.dig("trace_id")
78
+ break if message.role == "user" && trace.present? && trace != run.trace_id
79
+
80
+ slice << message
81
+ end
82
+
83
+ serialized = slice.map { |message| AgentMessageSerializer.call(message) }
84
+ instructions = run.output_metadata&.dig("instructions").presence || run.agent.instructions
85
+ if instructions.present?
86
+ serialized.unshift(
87
+ id: "run-#{run.id}-system",
88
+ role: "system",
89
+ content: instructions,
90
+ created_at: (run.started_at || run.created_at).iso8601(3)
91
+ )
92
+ end
93
+ serialized
94
+ end
95
+
96
+ def run_json(run, include_agent: false)
97
+ json = {
98
+ id: run.id,
99
+ status: run.status,
100
+ input_prompt: run.input_prompt,
101
+ input_params: run.input_params,
102
+ output: run.output,
103
+ output_metadata: run.output_metadata,
104
+ duration_ms: run.calculated_duration_ms,
105
+ input_tokens: run.input_tokens,
106
+ output_tokens: run.output_tokens,
107
+ total_tokens: run.total_tokens,
108
+ error_message: run.error_message,
109
+ trace_id: run.trace_id,
110
+ logs: run.logs,
111
+ started_at: run.started_at,
112
+ completed_at: run.completed_at,
113
+ created_at: run.created_at
114
+ }
115
+
116
+ if include_agent
117
+ json[:agent] = {
118
+ id: run.agent.id,
119
+ name: run.agent.name,
120
+ slug: run.agent.slug
121
+ }
122
+ end
123
+
124
+ json
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ class AgentsController < BaseController
6
+ # Ranking for the agent cards. Every dimension except "recent" reads the
7
+ # scorecard, which is computed in Ruby over both execution sources, so
8
+ # the ordering is applied there rather than in the SQL scope.
9
+ LIST_SORTS = {
10
+ "recent" => "Recently updated",
11
+ "popular" => "Most runs",
12
+ "longest" => "Longest average",
13
+ "cost" => "Highest cost",
14
+ "tokens" => "Most tokens"
15
+ }.freeze
16
+ DEFAULT_LIST_SORT = "recent"
17
+
18
+ before_action :set_agent, only: [ :show, :update, :destroy, :versions, :runs, :execute, :test, :restore, :duplicate, :export, :analytics ]
19
+ before_action :require_execution_enabled!, only: [ :execute, :test ]
20
+ before_action :require_owner!, only: [ :execute, :test ]
21
+ before_action :enforce_execution_quota!, only: [ :execute, :test ]
22
+
23
+ # GET /api/agents
24
+ def index
25
+ @agents = owner_agents.order(updated_at: :desc)
26
+
27
+ # Filter by status
28
+ @agents = @agents.where(status: params[:status]) if params[:status].present?
29
+
30
+ # Filter by provider
31
+ @agents = @agents.where(provider: params[:provider]) if params[:provider].present?
32
+
33
+ # Search by name
34
+ # LOWER(...) LIKE rather than ILIKE: the dashboard is not
35
+ # PostgreSQL-only.
36
+ if params[:q].present?
37
+ @agents = @agents.where("LOWER(name) LIKE ?", "%#{params[:q].to_s.downcase}%")
38
+ end
39
+
40
+ scorecards = AgentScorecard.for_agents(@agents)
41
+ cards = sort_cards(
42
+ @agents.map { |agent| agent_json(agent).merge(stats: scorecards[agent.id]) },
43
+ params[:sort]
44
+ )
45
+
46
+ render json: {
47
+ agents: cards,
48
+ meta: {
49
+ total: cards.size,
50
+ sorts: LIST_SORTS,
51
+ sort: list_sort(params[:sort]),
52
+ providers: Agent::PROVIDERS,
53
+ preset_types: Agent::PRESET_TYPES,
54
+ instruction_sets: Agent::INSTRUCTION_SETS,
55
+ available_tools: Agent::AVAILABLE_TOOLS
56
+ }
57
+ }
58
+ end
59
+
60
+ # GET /api/agents/:id
61
+ def show
62
+ render json: {
63
+ agent: agent_json(@agent, include_details: true),
64
+ versions: @agent.agent_versions.recent.limit(10).map { |v| version_json(v) },
65
+ recent_runs: @agent.agent_runs.recent.limit(5).map(&:summary)
66
+ }
67
+ end
68
+
69
+ # POST /api/agents
70
+ def create
71
+ @agent = owner_agents.build(agent_params)
72
+
73
+ if @agent.save
74
+ render json: { agent: agent_json(@agent, include_details: true) }, status: :created
75
+ else
76
+ render json: { errors: @agent.errors.full_messages }, status: :unprocessable_entity
77
+ end
78
+ end
79
+
80
+ # PATCH /api/agents/:id
81
+ def update
82
+ if @agent.update(agent_params)
83
+ render json: { agent: agent_json(@agent, include_details: true) }
84
+ else
85
+ render json: { errors: @agent.errors.full_messages }, status: :unprocessable_entity
86
+ end
87
+ end
88
+
89
+ # DELETE /api/agents/:id
90
+ def destroy
91
+ @agent.destroy
92
+ render json: { success: true }
93
+ end
94
+
95
+ # GET /api/agents/:id/versions
96
+ def versions
97
+ @versions = @agent.agent_versions.recent
98
+
99
+ render json: {
100
+ versions: @versions.map { |v| version_json(v, include_diff: true) }
101
+ }
102
+ end
103
+
104
+ # POST /api/agents/:id/restore
105
+ def restore
106
+ version = @agent.agent_versions.find(params[:version_id])
107
+ @agent.restore_from_version!(version)
108
+
109
+ render json: { agent: agent_json(@agent, include_details: true) }
110
+ end
111
+
112
+ # GET /api/agents/:id/runs
113
+ # Every execution of this agent, whoever ran it: dashboard runs and
114
+ # SDK-reported traces in one list, discriminated by `source`. Agents
115
+ # observed from telemetry have no AgentRun rows at all, so a runs-only
116
+ # list showed them as empty while their scorecard reported real traffic.
117
+ def runs
118
+ minutes = params[:minutes].presence&.then { |m| m.to_i.clamp(1, 60 * 24 * 90) }
119
+ page = (params[:page] || 1).to_i
120
+ per_page = (params[:per_page] || 20).to_i
121
+
122
+ executions = AgentExecutions.new(
123
+ agents: [ @agent ],
124
+ owner: current_owner,
125
+ window_minutes: minutes,
126
+ source: params[:source],
127
+ status: params[:status],
128
+ sort: params[:sort]
129
+ ).page(page: page, per_page: per_page)
130
+
131
+ # One digest->version map for the page; labels each run's instructions
132
+ # with the agent version that introduced them where one matches.
133
+ digest_versions = @agent.instructions_digest_versions
134
+ runs_by_id = AgentRun.where(id: executions[:rows].select { |r| r.source == "dashboard" }.map(&:id))
135
+ .index_by(&:id)
136
+
137
+ render json: {
138
+ runs: executions[:rows].map { |row| serialize_execution(row, runs_by_id, digest_versions) },
139
+ meta: {
140
+ page: page,
141
+ per_page: per_page,
142
+ total: executions[:total],
143
+ sources: AgentExecutions::SOURCES,
144
+ sorts: AgentExecutions::SORTS
145
+ }
146
+ }
147
+ end
148
+
149
+ # POST /api/agents/:id/execute
150
+ def execute
151
+ run = @agent.execute(
152
+ params[:prompt],
153
+ action: params[:action_name],
154
+ **params.fetch(:params, {}).to_unsafe_h.symbolize_keys
155
+ )
156
+ record_execution_usage
157
+
158
+ render json: { run: run.summary }, status: :accepted
159
+ end
160
+
161
+ # POST /api/agents/:id/test
162
+ def test
163
+ run = @agent.test_execute(
164
+ params[:prompt],
165
+ action: params[:action_name],
166
+ **params.fetch(:params, {}).to_unsafe_h.symbolize_keys
167
+ )
168
+ record_execution_usage
169
+
170
+ render json: { run: run.summary, output: run.output }
171
+ end
172
+
173
+ # POST /api/agents/:id/duplicate
174
+ def duplicate
175
+ new_agent = @agent.dup
176
+ new_agent.name = "#{@agent.name} (Copy)"
177
+ new_agent.slug = nil # Will be auto-generated
178
+ new_agent.status = :draft
179
+ new_agent.save!
180
+
181
+ render json: { agent: agent_json(new_agent, include_details: true) }, status: :created
182
+ end
183
+
184
+ # GET /api/agents/:id/export
185
+ def export
186
+ render json: {
187
+ agent: agent_json(@agent, include_details: true),
188
+ code: @agent.to_agent_class_code,
189
+ manifest: {
190
+ name: @agent.slug,
191
+ version: "1.0.0",
192
+ model: "#{@agent.provider}/#{@agent.model}",
193
+ description: @agent.description,
194
+ instructions: @agent.instructions,
195
+ tools: @agent.tools,
196
+ config: @agent.model_config
197
+ }
198
+ }
199
+ end
200
+
201
+ # GET /api/agents/:id/analytics
202
+ def analytics
203
+ days = (params[:days] || 30).to_i
204
+ start_date = days.days.ago.beginning_of_day
205
+
206
+ runs = @agent.agent_runs.where("created_at >= ?", start_date)
207
+
208
+ # Calculate stats
209
+ total_runs = runs.count
210
+ completed_runs = runs.where(status: :complete).count
211
+ failed_runs = runs.where(status: :failed).count
212
+ avg_duration = runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
213
+ total_tokens = runs.sum(:total_tokens)
214
+ avg_tokens = total_runs > 0 ? (total_tokens.to_f / total_runs).round : 0
215
+
216
+ # Runs by day
217
+ runs_by_day = runs.group("DATE(created_at)")
218
+ .select("DATE(created_at) as date, COUNT(*) as count, SUM(total_tokens) as tokens")
219
+ .order("date")
220
+ .map { |r| { date: r.date.to_s, count: r.count, tokens: r.tokens || 0 } }
221
+
222
+ # Status breakdown
223
+ status_breakdown = runs.group(:status).count.transform_keys(&:to_s)
224
+
225
+ # Recent errors
226
+ recent_errors = runs.failed_runs.recent.limit(5).map do |run|
227
+ {
228
+ id: run.id,
229
+ error: run.error_message&.truncate(200),
230
+ created_at: run.created_at
231
+ }
232
+ end
233
+
234
+ render json: {
235
+ period_days: days,
236
+ summary: {
237
+ total_runs: total_runs,
238
+ completed_runs: completed_runs,
239
+ failed_runs: failed_runs,
240
+ success_rate: total_runs > 0 ? ((completed_runs.to_f / total_runs) * 100).round(1) : 0,
241
+ avg_duration_ms: avg_duration,
242
+ total_tokens: total_tokens,
243
+ avg_tokens_per_run: avg_tokens
244
+ },
245
+ runs_by_day: runs_by_day,
246
+ status_breakdown: status_breakdown,
247
+ recent_errors: recent_errors
248
+ }
249
+ end
250
+
251
+ # GET /api/agents/presets
252
+ def presets
253
+ presets = Agent::PRESET_TYPES.map do |preset|
254
+ {
255
+ id: preset,
256
+ name: preset.titleize,
257
+ appearance: default_appearance_for(preset),
258
+ suggested_tools: suggested_tools_for(preset),
259
+ suggested_instructions: suggested_instructions_for(preset)
260
+ }
261
+ end
262
+
263
+ render json: { presets: presets }
264
+ end
265
+
266
+ private
267
+
268
+ def list_sort(requested)
269
+ LIST_SORTS.key?(requested.to_s) ? requested.to_s : DEFAULT_LIST_SORT
270
+ end
271
+
272
+ # Descending on the chosen dimension. Agents with nothing to rank (no
273
+ # runs, no priced spend) sort last instead of interleaving as zeroes,
274
+ # and run count breaks ties.
275
+ def sort_cards(cards, requested)
276
+ sort = list_sort(requested)
277
+ return cards if sort == "recent"
278
+
279
+ key = { "popular" => :runs, "longest" => :avg_duration_ms, "cost" => :cost, "tokens" => :tokens }[sort]
280
+
281
+ cards.sort_by do |card|
282
+ value = card[:stats]&.dig(key)
283
+ [ value.nil? ? 1 : 0, -value.to_f, -card[:stats].to_h[:runs].to_i ]
284
+ end
285
+ end
286
+
287
+ # Dashboard runs keep their full summary (logs, previews, instructions
288
+ # version); reported executions carry only what a trace knows. `source`
289
+ # tells the UI which it is holding.
290
+ def serialize_execution(row, runs_by_id, digest_versions)
291
+ run = runs_by_id[row.id] if row.source == "dashboard"
292
+
293
+ if run
294
+ run.summary.merge(
295
+ source: "dashboard",
296
+ # Priced here rather than on AgentRun so both sources use one
297
+ # estimator, and so a run and its trace never quote different costs.
298
+ cost: row.cost,
299
+ instructions_version: digest_versions[run.instructions_digest]
300
+ )
301
+ else
302
+ row.as_json.merge(id: row.to_param, record_id: row.id)
303
+ end
304
+ end
305
+
306
+ # Same contract as Api::SandboxesController#run: 402 + usage stats so the
307
+ # frontend can show the upgrade prompt.
308
+ def set_agent
309
+ @agent = owner_agents.find(params[:id])
310
+ end
311
+
312
+ def agent_params
313
+ params.require(:agent).permit(
314
+ :name, :description, :provider, :model, :instructions,
315
+ :preset_type, :agent_class_name, :status,
316
+ appearance: {},
317
+ action_prompts: [ :name, :prompt, :expose_as_tool ],
318
+ instruction_sets: [],
319
+ tools: [],
320
+ mcp_servers: [],
321
+ model_config: {},
322
+ response_format: {}
323
+ )
324
+ end
325
+
326
+ def agent_json(agent, include_details: false)
327
+ json = {
328
+ id: agent.id,
329
+ name: agent.name,
330
+ slug: agent.slug,
331
+ description: agent.description,
332
+ provider: agent.provider,
333
+ model: agent.model,
334
+ status: agent.status,
335
+ preset_type: agent.preset_type,
336
+ appearance: agent.appearance,
337
+ version_count: agent.version_count,
338
+ created_at: agent.created_at,
339
+ updated_at: agent.updated_at
340
+ }
341
+
342
+ if include_details
343
+ json.merge!(
344
+ instructions: agent.instructions,
345
+ action_prompts: agent.action_prompts,
346
+ instruction_sets: agent.instruction_sets,
347
+ tools: agent.tools,
348
+ mcp_servers: agent.mcp_servers,
349
+ model_config: agent.model_config,
350
+ response_format: agent.response_format,
351
+ agent_class_name: agent.agent_class_name,
352
+ telemetry_agent_class: agent.telemetry_agent_class
353
+ )
354
+ end
355
+
356
+ json
357
+ end
358
+
359
+ def version_json(version, include_diff: false)
360
+ json = {
361
+ id: version.id,
362
+ version_number: version.version_number,
363
+ change_summary: version.change_summary,
364
+ created_by: version.created_by,
365
+ created_at: version.created_at,
366
+ is_latest: version.latest?
367
+ }
368
+
369
+ if include_diff && version.previous
370
+ json[:diff] = version.diff(version.previous)
371
+ end
372
+
373
+ json
374
+ end
375
+
376
+ def default_appearance_for(preset)
377
+ appearances = {
378
+ "terminal" => { hat: "fedora", heldItem: "terminal", theme: "emerald" },
379
+ "webDeveloper" => { hat: "safari", heldItem: "browser", theme: "blue" },
380
+ "documentAnalysis" => { hat: "fedora", heldItem: "document", theme: "amber" },
381
+ "writing" => { hat: "fedora", hatAccessory: "feather", heldItem: "scroll", theme: "purple" },
382
+ "research" => { hat: "safari", heldItem: "magnifyingGlass", theme: "teal" },
383
+ "playwright" => { hat: "fedora", hatAccessory: "theaterMasks", heldItem: "browser", theme: "rose" }
384
+ }
385
+ appearances[preset] || { theme: "default" }
386
+ end
387
+
388
+ def suggested_tools_for(preset)
389
+ tools = {
390
+ "terminal" => %w[terminal filesystem code],
391
+ "webDeveloper" => %w[terminal filesystem code playwright],
392
+ "documentAnalysis" => %w[filesystem search],
393
+ "writing" => %w[edit translate],
394
+ "research" => %w[fetch search memory],
395
+ "playwright" => %w[playwright filesystem]
396
+ }
397
+ tools[preset] || []
398
+ end
399
+
400
+ def suggested_instructions_for(preset)
401
+ instructions = {
402
+ "terminal" => %w[github docker kubernetes],
403
+ "webDeveloper" => %w[github ruby rails typescript],
404
+ "research" => %w[github python],
405
+ "playwright" => %w[github typescript]
406
+ }
407
+ instructions[preset] || []
408
+ end
409
+ end
410
+ end
411
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ class AnalyticsController < BaseController
6
+ # GET /api/analytics
7
+ def index
8
+ days = (params[:days] || 30).to_i
9
+ start_date = days.days.ago.beginning_of_day
10
+
11
+ # Table names are interpolated rather than written literally: the
12
+ # engine's tables carry a configurable prefix.
13
+ runs_table = AgentRun.table_name
14
+ agents_table = ActionAgent::Agent.table_name
15
+
16
+ agents = owner_agents
17
+ runs = AgentRun.joins(:agent).where(agent: agents).where("#{runs_table}.created_at >= ?", start_date)
18
+
19
+ # Overall stats
20
+ total_agents = agents.count
21
+ active_agents = agents.where(status: :active).count
22
+ total_runs = runs.count
23
+ completed_runs = runs.where(status: :complete).count
24
+ failed_runs = runs.where(status: :failed).count
25
+
26
+ # Token usage
27
+ total_tokens = runs.sum(:total_tokens)
28
+ total_input_tokens = runs.sum(:input_tokens)
29
+ total_output_tokens = runs.sum(:output_tokens)
30
+
31
+ # Average metrics
32
+ avg_duration = runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
33
+ avg_tokens_per_run = total_runs > 0 ? (total_tokens.to_f / total_runs).round : 0
34
+
35
+ # Runs over time
36
+ runs_by_day = runs.group("DATE(#{runs_table}.created_at)")
37
+ .select("DATE(#{runs_table}.created_at) as date, COUNT(*) as count")
38
+ .order("date")
39
+ .map { |r| { date: r.date.to_s, count: r.count } }
40
+
41
+ # Token usage over time
42
+ tokens_by_day = runs.group("DATE(#{runs_table}.created_at)")
43
+ .select("DATE(#{runs_table}.created_at) as date, SUM(total_tokens) as tokens")
44
+ .order("date")
45
+ .map { |r| { date: r.date.to_s, tokens: r.tokens || 0 } }
46
+
47
+ # Top agents by usage
48
+ top_agents = agents.joins(:agent_runs)
49
+ .where("#{runs_table}.created_at >= ?", start_date)
50
+ .group("#{agents_table}.id", "#{agents_table}.name", "#{agents_table}.slug")
51
+ .select(
52
+ "#{agents_table}.id, #{agents_table}.name, #{agents_table}.slug, " \
53
+ "COUNT(#{runs_table}.id) as run_count, SUM(#{runs_table}.total_tokens) as total_tokens"
54
+ )
55
+ .order("run_count DESC")
56
+ .limit(5)
57
+ .map { |a| { id: a.id, name: a.name, slug: a.slug, run_count: a.run_count, total_tokens: a.total_tokens || 0 } }
58
+
59
+ # Status distribution
60
+ status_breakdown = runs.group(:status).count.transform_keys(&:to_s)
61
+
62
+ # Provider distribution
63
+ provider_breakdown = runs.joins(:agent)
64
+ .group("#{agents_table}.provider")
65
+ .count
66
+ .transform_keys(&:to_s)
67
+
68
+ render json: {
69
+ period_days: days,
70
+ summary: {
71
+ total_agents: total_agents,
72
+ active_agents: active_agents,
73
+ total_runs: total_runs,
74
+ completed_runs: completed_runs,
75
+ failed_runs: failed_runs,
76
+ success_rate: total_runs > 0 ? ((completed_runs.to_f / total_runs) * 100).round(1) : 0,
77
+ avg_duration_ms: avg_duration,
78
+ total_tokens: total_tokens,
79
+ total_input_tokens: total_input_tokens,
80
+ total_output_tokens: total_output_tokens,
81
+ avg_tokens_per_run: avg_tokens_per_run
82
+ },
83
+ charts: {
84
+ runs_by_day: runs_by_day,
85
+ tokens_by_day: tokens_by_day
86
+ },
87
+ top_agents: top_agents,
88
+ status_breakdown: status_breakdown,
89
+ provider_breakdown: provider_breakdown
90
+ }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Platform API keys (Settings -> API Keys). The full token is returned
6
+ # exactly once — in the create response — and masked everywhere else.
7
+ class ApiKeysController < BaseController
8
+ before_action :require_owner!
9
+
10
+ # GET /api/api_keys
11
+ def index
12
+ render json: {
13
+ api_keys: owned(ApiKey).order(created_at: :desc).map { |key| serialize(key) }
14
+ }
15
+ end
16
+
17
+ # POST /api/api_keys
18
+ def create
19
+ api_key = owned(ApiKey).create!(name: params.require(:name))
20
+
21
+ render json: {
22
+ api_key: serialize(api_key).merge(token: api_key.token)
23
+ }, status: :created
24
+ end
25
+
26
+ # DELETE /api/api_keys/:id
27
+ def destroy
28
+ owned(ApiKey).find(params[:id]).destroy!
29
+ head :no_content
30
+ end
31
+
32
+ private
33
+
34
+ def serialize(api_key)
35
+ {
36
+ id: api_key.id,
37
+ name: api_key.name,
38
+ masked_token: api_key.masked_token,
39
+ created_at: api_key.created_at.iso8601,
40
+ last_used_at: api_key.last_used_at&.iso8601
41
+ }
42
+ end
43
+ end
44
+ end
45
+ end