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,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Exposes the dashboard's agents as an authenticated MCP (Model Context
6
+ # Protocol) service over Streamable HTTP JSON-RPC — agents present
7
+ # themselves as Resource Agents backed by their ActiveRecord state:
8
+ #
9
+ # - tools/list & tools/call: each agent is a callable tool (run_<slug>)
10
+ # that executes a synchronous generation run.
11
+ # - resources/list & resources/read: each agent is an agent://<slug>
12
+ # resource whose content is its live scorecard (config + stats + memory
13
+ # summary from the solid_agent datasets).
14
+ #
15
+ # Authentication/authorization: Bearer <api key> (Settings -> API Keys).
16
+ # The key scopes everything to its account — its members' agents and its
17
+ # run quotas.
18
+ #
19
+ # Connect from an MCP client with:
20
+ # { "type": "http", "url": "https://activeagents.ai/mcp",
21
+ # "headers": { "Authorization": "Bearer aa_..." } }
22
+ class McpController < BaseController
23
+ # Authenticated by API key rather than by the host app's sessions.
24
+ allow_unauthenticated_access
25
+ before_action :authenticate_api_key!
26
+
27
+ PROTOCOL_VERSION = "2025-03-26"
28
+ JSONRPC_METHOD_NOT_FOUND = -32601
29
+ JSONRPC_INVALID_PARAMS = -32602
30
+ JSONRPC_SERVER_ERROR = -32000
31
+
32
+ # POST /mcp
33
+ def create
34
+ request_id = params[:id]
35
+
36
+ # JSON-RPC notifications get no response body.
37
+ return head :accepted if request_id.nil? && params[:method].to_s.start_with?("notifications/")
38
+
39
+ result = case params[:method]
40
+ when "initialize" then initialize_result
41
+ when "ping" then {}
42
+ when "tools/list" then tools_list
43
+ when "tools/call" then tools_call
44
+ when "resources/list" then resources_list
45
+ when "resources/read" then resources_read
46
+ else
47
+ return render_error(request_id, JSONRPC_METHOD_NOT_FOUND, "Method not found: #{params[:method]}")
48
+ end
49
+
50
+ render json: { jsonrpc: "2.0", id: request_id, result: result }
51
+ rescue McpError => e
52
+ render_error(request_id, e.code, e.message)
53
+ rescue StandardError => e
54
+ Rails.logger.error("[Api::McpController] #{e.class}: #{e.message}")
55
+ render_error(request_id, JSONRPC_SERVER_ERROR, "Internal error")
56
+ end
57
+
58
+ private
59
+
60
+ # JSON-RPC errors ride on HTTP 200 per the MCP Streamable HTTP transport.
61
+ def render_error(id, code, message)
62
+ render json: { jsonrpc: "2.0", id: id, error: { code: code, message: message } }
63
+ end
64
+
65
+ class McpError < StandardError
66
+ attr_reader :code
67
+
68
+ def initialize(message, code = JSONRPC_SERVER_ERROR)
69
+ super(message)
70
+ @code = code
71
+ end
72
+ end
73
+
74
+ def authenticate_api_key!
75
+ token = request.headers["Authorization"].to_s[/\ABearer\s+(.+)\z/i, 1]
76
+ api_key = ApiKey.authenticate(token)
77
+
78
+ if api_key.nil?
79
+ render json: { error: "Unauthorized: pass a dashboard API key as a Bearer token" }, status: :unauthorized
80
+ return
81
+ end
82
+
83
+ api_key.touch_last_used!
84
+ @api_key = api_key
85
+ @owner = api_key.owner
86
+ end
87
+
88
+ # The agents this key can reach. A key belongs to whoever owns it, and
89
+ # a single-user install has no owner, so the key reaches every agent
90
+ # the dashboard holds.
91
+ def key_agents
92
+ ActionAgent.agents_for(@owner).where.not(status: :archived).order(:slug)
93
+ end
94
+
95
+ def initialize_result
96
+ {
97
+ protocolVersion: PROTOCOL_VERSION,
98
+ capabilities: { tools: {}, resources: {} },
99
+ serverInfo: { name: "activeagents", version: "1.0" },
100
+ instructions: "Each tool runs one of this account's agents. Each agent://<slug> resource returns the agent's live scorecard."
101
+ }
102
+ end
103
+
104
+ MESSAGE_INPUT_SCHEMA = {
105
+ type: "object",
106
+ properties: {
107
+ message: { type: "string", description: "The prompt/message for the agent" }
108
+ },
109
+ required: [ "message" ]
110
+ }.freeze
111
+
112
+ def tools_list
113
+ tools = key_agents.flat_map do |agent|
114
+ agent_tools = [ {
115
+ name: "run_#{agent.slug}",
116
+ description: agent.description.presence || "Run the #{agent.name} agent",
117
+ inputSchema: MESSAGE_INPUT_SCHEMA
118
+ } ]
119
+ # Named actions marked expose_as_tool are individually callable —
120
+ # the activeagent "actions as tools" pattern over MCP.
121
+ Array(agent.action_prompts).select { |ap| ap["expose_as_tool"] }.each do |action|
122
+ agent_tools << {
123
+ name: "run_#{agent.slug}__#{action['name']}",
124
+ description: "Run the #{agent.name} agent's #{action['name']} action",
125
+ inputSchema: MESSAGE_INPUT_SCHEMA
126
+ }
127
+ end
128
+ agent_tools
129
+ end
130
+
131
+ { tools: tools }
132
+ end
133
+
134
+ def tools_call
135
+ name = params.dig(:params, :name).to_s
136
+ slug, action = name.delete_prefix("run_").split("__", 2)
137
+ agent = key_agents.find_by(slug: slug)
138
+ raise McpError.new("Unknown tool: #{name}", JSONRPC_INVALID_PARAMS) unless agent
139
+ if action.present? && agent.action_prompt_for(action)&.dig("expose_as_tool") != true
140
+ raise McpError.new("Unknown tool: #{name}", JSONRPC_INVALID_PARAMS)
141
+ end
142
+
143
+ message = params.dig(:params, :arguments, :message).to_s
144
+ raise McpError.new("Missing required argument: message", JSONRPC_INVALID_PARAMS) if message.blank?
145
+
146
+ unless ActionAgent.execution_enabled?
147
+ raise McpError.new("Agent execution is disabled on this dashboard")
148
+ end
149
+ if (denial = ActionAgent.quota_denial(@owner, :execution)).present?
150
+ raise McpError.new(denial.is_a?(Hash) ? denial[:message] || denial["message"] : denial)
151
+ end
152
+
153
+ run = agent.test_execute(message, action: action)
154
+ ActionAgent.record_usage(@owner, :execution)
155
+
156
+ if run.failed?
157
+ { content: [ { type: "text", text: "Agent run failed: #{run.error_message}" } ], isError: true }
158
+ else
159
+ {
160
+ content: [ { type: "text", text: run.output.to_s } ],
161
+ structuredContent: {
162
+ run_id: run.id,
163
+ trace_id: run.trace_id,
164
+ duration_ms: run.duration_ms,
165
+ total_tokens: run.total_tokens,
166
+ metadata: run.output_metadata
167
+ }
168
+ }
169
+ end
170
+ end
171
+
172
+ def resources_list
173
+ {
174
+ resources: key_agents.map do |agent|
175
+ {
176
+ uri: "agent://#{agent.slug}",
177
+ name: agent.name,
178
+ description: agent.description.presence || "#{agent.name} scorecard",
179
+ mimeType: "application/json"
180
+ }
181
+ end
182
+ }
183
+ end
184
+
185
+ def resources_read
186
+ uri = params.dig(:params, :uri).to_s
187
+ slug = uri.delete_prefix("agent://")
188
+ agent = key_agents.find_by(slug: slug)
189
+ raise McpError.new("Unknown resource: #{uri}", JSONRPC_INVALID_PARAMS) unless agent
190
+
191
+ {
192
+ contents: [ {
193
+ uri: uri,
194
+ mimeType: "application/json",
195
+ text: agent_resource(agent).to_json
196
+ } ]
197
+ }
198
+ end
199
+
200
+ # The Resource Agent card: configuration plus live scorecard stats and
201
+ # the agent's memory summary (solid_agent datasets).
202
+ def agent_resource(agent)
203
+ {
204
+ name: agent.name,
205
+ slug: agent.slug,
206
+ description: agent.description,
207
+ provider: agent.provider,
208
+ model: agent.model,
209
+ status: agent.status,
210
+ tools: agent.tools,
211
+ instructions: agent.instructions,
212
+ stats: AgentScorecard.for_agents([ agent ])[agent.id],
213
+ memory: agent.memory.summary_list.last(20)
214
+ }
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Read API for MCP services, plus sandbox provisioning for the servers
6
+ # that can be started on demand. Backs the dashboard MCP Services view.
7
+ #
8
+ # The list is the union of three things: servers detected from telemetry
9
+ # and solid_agent records (ToolDiscovery), servers an agent declares in
10
+ # its configuration, and the default catalog (McpCatalog). An install
11
+ # therefore sees both what it is already using and what it could turn on.
12
+ class McpServersController < BaseController
13
+ before_action :require_owner!
14
+ # Launching provisions a sandbox and runs a server in it, so it answers
15
+ # to the same two gates as any other execution: the read-only kill
16
+ # switch, and whatever limits the host app imposes.
17
+ before_action :require_execution_enabled!, only: [ :launch ]
18
+ before_action :enforce_execution_quota!, only: [ :launch ]
19
+ before_action :set_catalog_entry, only: [ :show, :launch ]
20
+
21
+ STATUS_LABELS = {
22
+ "active" => "Called in this window",
23
+ "configured" => "Declared by an agent, no traffic yet",
24
+ "available" => "Available to connect",
25
+ "idle" => "Seen previously, no traffic in this window"
26
+ }.freeze
27
+
28
+ # GET /api/mcp_servers
29
+ def index
30
+ finder = discovery
31
+ tools = finder.detected_tools
32
+ servers = finder.servers(tools)
33
+
34
+ render json: {
35
+ servers: servers,
36
+ catalog: McpCatalog.all,
37
+ summary: summary_for(servers),
38
+ sandboxes: active_sandboxes,
39
+ window_hours: finder.window_hours,
40
+ statuses: STATUS_LABELS
41
+ }
42
+ end
43
+
44
+ # GET /api/mcp_servers/:id
45
+ #
46
+ # One server with the tools detected for it, so the view can expand a
47
+ # row without refetching the whole inventory.
48
+ def show
49
+ finder = discovery
50
+ tools = finder.detected_tools
51
+ server = finder.servers(tools).find { |row| row[:key] == params[:id] }
52
+
53
+ render json: {
54
+ server: server || @catalog_entry,
55
+ tools: tools.select { |tool| tool[:mcp_server] == params[:id] }
56
+ }
57
+ end
58
+
59
+ # POST /api/mcp_servers/:id/launch
60
+ #
61
+ # Starts the server inside a sandbox session. Only catalog entries
62
+ # marked +sandbox: true+ are launchable — the rest need credentials
63
+ # the dashboard has nowhere safe to source, so they are listed but not
64
+ # startable.
65
+ def launch
66
+ unless @catalog_entry[:sandbox]
67
+ return render json: {
68
+ error: "#{@catalog_entry[:name]} can't be started from the dashboard",
69
+ reason: launch_blocked_reason(@catalog_entry)
70
+ }, status: :unprocessable_entity
71
+ end
72
+
73
+ sandbox = SandboxSession.new(
74
+ sandbox_type: @catalog_entry[:sandbox_type] || "terminal",
75
+ mcp_servers: [ @catalog_entry[:key] ]
76
+ )
77
+ # A sandbox belongs to whoever opened it, which is how
78
+ # SandboxesController assigns them too — not to whatever
79
+ # `current_owner` resolves to, since that is the account in a
80
+ # multi-tenant install and this model prefers :user. Both are set
81
+ # when the host app has them; a single-user install declares neither
82
+ # association, so both are skipped.
83
+ # respond_to? alone isn't enough: the association is declared from
84
+ # configuration, but a host app's pre-existing table may not carry
85
+ # the column behind it.
86
+ assign_owner(sandbox, :user, current_user)
87
+ assign_owner(sandbox, :account, current_account)
88
+
89
+ if sandbox.save
90
+ sandbox.provision!
91
+ sandbox.reload
92
+ record_execution_usage
93
+ render json: { sandbox: sandbox.summary, server: @catalog_entry }, status: :created
94
+ else
95
+ render json: { error: sandbox.errors.full_messages }, status: :unprocessable_entity
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def discovery
102
+ ToolDiscovery.new(traces: owned_traces, agents: owner_agents, hours: window_hours)
103
+ end
104
+
105
+ def assign_owner(sandbox, association, record)
106
+ return if record.nil?
107
+ return unless sandbox.respond_to?(:"#{association}=")
108
+ return unless sandbox.has_attribute?(:"#{association}_id")
109
+
110
+ sandbox.public_send(:"#{association}=", record)
111
+ end
112
+
113
+ def set_catalog_entry
114
+ @catalog_entry = McpCatalog.find(params[:id])
115
+ render json: { error: "Unknown MCP server: #{params[:id]}" }, status: :not_found if @catalog_entry.nil?
116
+ end
117
+
118
+ def launch_blocked_reason(entry)
119
+ if entry[:requires_credentials].any?
120
+ "Needs #{entry[:requires_credentials].to_sentence}. Configure it on an agent instead."
121
+ else
122
+ "This server runs outside the sandbox environment."
123
+ end
124
+ end
125
+
126
+ def summary_for(servers)
127
+ {
128
+ total: servers.size,
129
+ active: servers.count { |server| server[:status] == "active" },
130
+ configured: servers.count { |server| server[:status] == "configured" },
131
+ available: servers.count { |server| server[:status] == "available" },
132
+ launchable: servers.count { |server| server[:launchable] },
133
+ # Servers seen in traffic that the catalog doesn't describe — worth
134
+ # surfacing, since they're the ones nobody documented.
135
+ unknown: servers.count { |server| !server[:known] },
136
+ total_calls: servers.sum { |server| server[:calls] }
137
+ }
138
+ end
139
+
140
+ # Running sandboxes started with an MCP server, so the view can show
141
+ # "running" beside the launch button instead of starting a second copy.
142
+ def active_sandboxes
143
+ owned(SandboxSession)
144
+ .active
145
+ .where(SandboxSession.json_array_not_empty_sql(:mcp_servers))
146
+ .recent
147
+ .limit(20)
148
+ .map { |sandbox| sandbox.summary.merge(mcp_servers: Array(sandbox.mcp_servers)) }
149
+ end
150
+
151
+ def window_hours
152
+ params.fetch(:hours, ToolDiscovery::DEFAULT_WINDOW_HOURS).to_i
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Read API for telemetry metrics, backing the dashboard Metrics view.
6
+ #
7
+ # Exposes the same aggregates as the gem dashboard's metrics page
8
+ # (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.
12
+ class MetricsController < BaseController
13
+ before_action :require_owner!
14
+
15
+ DEFAULT_WINDOW_HOURS = 24
16
+ MAX_WINDOW_HOURS = 24 * 30
17
+
18
+ # How to rank the per-agent table. Cost is applied after the grouped
19
+ # query because pricing happens in Ruby (rates vary per model), so all
20
+ # four are ordered in one place rather than half in SQL.
21
+ AGENT_SORTS = {
22
+ "popular" => "Most requests",
23
+ "longest" => "Longest average",
24
+ "cost" => "Highest cost",
25
+ "tokens" => "Most tokens",
26
+ "errors" => "Most errors"
27
+ }.freeze
28
+ DEFAULT_AGENT_SORT = "popular"
29
+
30
+ # GET /api/metrics
31
+ def show
32
+ hours = params.fetch(:hours, DEFAULT_WINDOW_HOURS).to_i.clamp(1, MAX_WINDOW_HOURS)
33
+ now = Time.current
34
+
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))
37
+
38
+ costs = cost_statistics(current)
39
+ priced = agent_statistics(current).map { |row| row.merge(cost: costs[:by_agent][row[:name]] || 0.0) }
40
+
41
+ render json: {
42
+ summary: summary_for(current, previous).merge(total_cost: costs[:total]),
43
+ hourly_requests: hourly_requests(current, hours, now),
44
+ by_agent: sort_agents(priced, params[:sort]),
45
+ window_hours: hours,
46
+ sorts: AGENT_SORTS,
47
+ sort: agent_sort(params[:sort])
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ def agent_sort(requested)
54
+ AGENT_SORTS.key?(requested.to_s) ? requested.to_s : DEFAULT_AGENT_SORT
55
+ end
56
+
57
+ # Descending on the chosen dimension; request count breaks ties so the
58
+ # table keeps a stable, meaningful secondary order.
59
+ def sort_agents(rows, requested)
60
+ key = case agent_sort(requested)
61
+ when "longest" then :avg_duration_ms
62
+ when "cost" then :cost
63
+ when "tokens" then :tokens
64
+ when "errors" then :errors
65
+ else :requests
66
+ end
67
+
68
+ rows.sort_by { |row| [ -row[key].to_f, -row[:requests].to_i ] }
69
+ end
70
+
71
+ def traces_scope
72
+ ActionAgent.trace_model.for_account(current_account)
73
+ end
74
+
75
+ # Same definitions as the gem dashboard's calculate_metrics, with
76
+ # previous-period percentage changes layered on top.
77
+ def summary_for(current, previous)
78
+ total = current.count
79
+ previous_total = previous.count
80
+
81
+ input_tokens = current.sum(:total_input_tokens)
82
+ output_tokens = current.sum(:total_output_tokens)
83
+ thinking_tokens = current.sum(:total_thinking_tokens)
84
+
85
+ avg_latency = current.average(:total_duration_ms)&.round(0)
86
+ previous_avg_latency = previous.average(:total_duration_ms)&.round(0)
87
+
88
+ error_rate = total.positive? ? (current.with_errors.count.to_f / total * 100).round(2) : 0.0
89
+
90
+ {
91
+ total_requests: total,
92
+ requests_change: percent_change(previous_total, total),
93
+ avg_latency_ms: avg_latency || 0,
94
+ latency_change: percent_change(previous_avg_latency, avg_latency),
95
+ error_rate: error_rate,
96
+ errors: current.with_errors.count,
97
+ unique_agents: current.distinct.count(:agent_class),
98
+ tokens_used: input_tokens + output_tokens + thinking_tokens,
99
+ tokens_input: input_tokens,
100
+ tokens_output: output_tokens,
101
+ tokens_thinking: thinking_tokens
102
+ }
103
+ end
104
+
105
+ def hourly_requests(scope, hours, now)
106
+ traces = ActionAgent.trace_model
107
+ bucket_sql = Arel.sql(traces.hour_bucket_sql)
108
+ to_epoch = ->(hash) { hash.transform_keys { |key| traces.hour_bucket_epoch(key) } }
109
+
110
+ counts = to_epoch.call(scope.group(bucket_sql).count)
111
+ latencies = to_epoch.call(scope.group(bucket_sql).average(:total_duration_ms))
112
+
113
+ start_hour = (now - (hours - 1).hours).beginning_of_hour
114
+ (0...hours).map do |offset|
115
+ bucket = start_hour + offset.hours
116
+ {
117
+ hour: bucket.strftime("%H:00"),
118
+ timestamp: bucket.iso8601,
119
+ count: counts[bucket.to_i] || 0,
120
+ avg_latency_ms: latencies[bucket.to_i]&.round(0) || 0,
121
+ active: offset == hours - 1
122
+ }
123
+ end
124
+ end
125
+
126
+ # Mirrors the gem dashboard's agent_statistics grouped query.
127
+ def agent_statistics(scope)
128
+ scope
129
+ .where.not(agent_class: nil)
130
+ .group(:agent_class)
131
+ .select(
132
+ "agent_class",
133
+ "COUNT(*) AS trace_count",
134
+ "SUM(total_input_tokens + total_output_tokens + total_thinking_tokens) AS token_sum",
135
+ "AVG(total_duration_ms) AS avg_duration",
136
+ "SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) AS error_count"
137
+ )
138
+ .order(Arel.sql("trace_count DESC"))
139
+ .map do |row|
140
+ {
141
+ name: row.agent_class,
142
+ requests: row.trace_count,
143
+ tokens: row.token_sum.to_i,
144
+ avg_duration_ms: row.avg_duration&.round(0) || 0,
145
+ errors: row.error_count.to_i
146
+ }
147
+ end
148
+ end
149
+
150
+ # Estimated spend for the window, total and per agent class. The model
151
+ # lives inside the spans JSON (first llm span).
152
+ def cost_statistics(scope)
153
+ rows = ActionAgent.trace_model.pluck_with_llm_model(
154
+ scope, :agent_class, :total_input_tokens, :total_output_tokens
155
+ )
156
+
157
+ by_agent = Hash.new(0.0)
158
+ total = 0.0
159
+
160
+ rows.each do |model, agent_class, input_tokens, output_tokens|
161
+ cost = ModelPricing.estimate(model: model, input_tokens: input_tokens, output_tokens: output_tokens)
162
+ next unless cost
163
+
164
+ total += cost
165
+ by_agent[agent_class] += cost if agent_class
166
+ end
167
+
168
+ { total: total.round(4), by_agent: by_agent.transform_values { |v| v.round(4) } }
169
+ end
170
+
171
+ def percent_change(previous, current)
172
+ return nil if previous.nil? || current.nil? || previous.to_f.zero?
173
+
174
+ (((current.to_f - previous.to_f) / previous.to_f) * 100).round(1)
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Per-account LLM provider credentials (Settings -> Provider API Keys).
6
+ # API keys are write-only: responses carry a masked hint, never the key.
7
+ # Ollama's credential is a host URL and is echoed back in full.
8
+ class ProviderKeysController < BaseController
9
+ before_action :require_owner!
10
+
11
+ # GET /api/provider_keys — one row per supported provider, configured or not.
12
+ def index
13
+ configured = owned(ProviderKey).index_by(&:provider)
14
+
15
+ render json: {
16
+ provider_keys: ProviderKey::PROVIDERS.map do |provider|
17
+ serialize(provider, configured[provider])
18
+ end
19
+ }
20
+ end
21
+
22
+ # POST /api/provider_keys — upserts the credential for a provider.
23
+ def create
24
+ provider = params.require(:provider)
25
+ credential = params.require(:credential)
26
+
27
+ record = owned(ProviderKey).find_or_initialize_by(provider: provider)
28
+ record.update!(credential: credential)
29
+
30
+ render json: { provider_key: serialize(provider, record) }, status: :created
31
+ end
32
+
33
+ # DELETE /api/provider_keys/:provider
34
+ def destroy
35
+ owned(ProviderKey).find_by!(provider: params[:provider]).destroy!
36
+ head :no_content
37
+ end
38
+
39
+ private
40
+
41
+ def serialize(provider, record)
42
+ {
43
+ provider: provider,
44
+ host_based: ProviderKey::HOST_PROVIDERS.include?(provider),
45
+ configured: record.present?,
46
+ hint: record&.display_hint,
47
+ updated_at: record&.updated_at&.iso8601
48
+ }
49
+ end
50
+ end
51
+ end
52
+ end