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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +103 -0
- data/app/assets/builds/action_agent.css +2 -0
- data/app/assets/builds/action_agent.js +163 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
- data/app/controllers/action_agent/api/agents_controller.rb +411 -0
- data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
- data/app/controllers/action_agent/api/base_controller.rb +112 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
- data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
- data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
- data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
- data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
- data/app/controllers/action_agent/api/templates_controller.rb +94 -0
- data/app/controllers/action_agent/api/tools_controller.rb +58 -0
- data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
- data/app/controllers/action_agent/api/traces_controller.rb +136 -0
- data/app/controllers/action_agent/application_controller.rb +105 -0
- data/app/controllers/action_agent/dashboard_controller.rb +104 -0
- data/app/controllers/action_agent/traces_controller.rb +121 -0
- data/app/jobs/action_agent/agent_execution_job.rb +52 -0
- data/app/jobs/action_agent/application_job.rb +12 -0
- data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
- data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
- data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
- data/app/jobs/action_agent/trace_retention_job.rb +57 -0
- data/app/models/action_agent/agent.rb +343 -0
- data/app/models/action_agent/agent_context.rb +129 -0
- data/app/models/action_agent/agent_generation.rb +48 -0
- data/app/models/action_agent/agent_memory.rb +50 -0
- data/app/models/action_agent/agent_memory_entry.rb +14 -0
- data/app/models/action_agent/agent_message.rb +43 -0
- data/app/models/action_agent/agent_run.rb +151 -0
- data/app/models/action_agent/agent_template.rb +182 -0
- data/app/models/action_agent/agent_version.rb +48 -0
- data/app/models/action_agent/api_key.rb +53 -0
- data/app/models/action_agent/application_record.rb +27 -0
- data/app/models/action_agent/evaluation.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +20 -0
- data/app/models/action_agent/model_pricing.rb +80 -0
- data/app/models/action_agent/provider_key.rb +60 -0
- data/app/models/action_agent/recording_action.rb +119 -0
- data/app/models/action_agent/recording_snapshot.rb +88 -0
- data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
- data/app/models/action_agent/sandbox_run.rb +45 -0
- data/app/models/action_agent/sandbox_session.rb +160 -0
- data/app/models/action_agent/session_recording.rb +178 -0
- data/app/models/action_agent/telemetry_trace.rb +357 -0
- data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
- data/app/models/concerns/action_agent/ownable.rb +86 -0
- data/app/models/concerns/action_agent/session_recordable.rb +91 -0
- data/app/queries/action_agent/agent_executions.rb +201 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
- data/app/serializers/action_agent/interaction_preview.rb +22 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
- data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
- data/app/services/action_agent/agent_execution_service.rb +572 -0
- data/app/services/action_agent/agent_registrar.rb +197 -0
- data/app/services/action_agent/agent_scorecard.rb +191 -0
- data/app/services/action_agent/agent_toolbox.rb +504 -0
- data/app/services/action_agent/evaluation_runner_service.rb +481 -0
- data/app/services/action_agent/mcp_catalog.rb +247 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
- data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
- data/app/services/action_agent/playwright_mcp_client.rb +148 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
- data/app/services/action_agent/session_recording_service.rb +228 -0
- data/app/services/action_agent/tool_discovery.rb +617 -0
- data/app/views/action_agent/dashboard/index.html.erb +5 -0
- data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
- data/app/views/action_agent/traces/index.html.erb +135 -0
- data/app/views/action_agent/traces/metrics.html.erb +145 -0
- data/app/views/action_agent/traces/show.html.erb +36 -0
- data/app/views/layouts/action_agent/application.html.erb +94 -0
- data/app/views/layouts/action_agent/react.html.erb +19 -0
- data/config/routes.rb +144 -0
- data/lib/action_agent/compatibility.rb +49 -0
- data/lib/action_agent/engine.rb +51 -0
- data/lib/action_agent/version.rb +5 -0
- data/lib/action_agent.rb +388 -0
- data/lib/actionagent.rb +6 -0
- data/lib/generators/action_agent/install_generator.rb +137 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
- data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
- data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
- 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
|