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,617 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Builds the dashboard's tool and MCP-server inventory by reading what
|
|
5
|
+
# agents actually called, rather than what someone remembered to register.
|
|
6
|
+
#
|
|
7
|
+
# Four record sources are merged, because each one sees a different slice
|
|
8
|
+
# of the same traffic:
|
|
9
|
+
#
|
|
10
|
+
# * **The offered tool roster** — the tools array in each generation
|
|
11
|
+
# request, recorded on the prompt span as +prompt.input.tools+ and on
|
|
12
|
+
# each solid_agent generation as +provenance["tools"]+. The only source
|
|
13
|
+
# that sees a tool the model was given and never called, and the only
|
|
14
|
+
# one carrying descriptions and parameter names.
|
|
15
|
+
# * **Telemetry tool spans** — the only source for agents running in a
|
|
16
|
+
# host app and reporting in over the wire, and where timing and error
|
|
17
|
+
# rates come from.
|
|
18
|
+
# * **solid_agent generations** (+agent_generations.tool_calls+) — every
|
|
19
|
+
# tool call the model *requested*, including ones that never produced a
|
|
20
|
+
# span because the run died first.
|
|
21
|
+
# * **solid_agent messages** (+agent_messages+ with +role: "tool"+) — the
|
|
22
|
+
# results that came back, which is where a tool's arguments survive even
|
|
23
|
+
# when telemetry is disabled.
|
|
24
|
+
#
|
|
25
|
+
# Counting a tool once per source would multiply-count a dashboard-executed
|
|
26
|
+
# run, which writes all four. So each source feeds a distinct counter
|
|
27
|
+
# (+calls+, +requested+, +results+) and +calls+ falls back to the largest
|
|
28
|
+
# observed count when telemetry is absent — a self-hosted install with
|
|
29
|
+
# telemetry off still gets real numbers instead of zeros.
|
|
30
|
+
#
|
|
31
|
+
# MCP attribution comes from ActiveAgent::Telemetry::ToolOrigin (the
|
|
32
|
+
# +mcp__server__tool+ convention, tagged onto spans at instrumentation
|
|
33
|
+
# time), then McpCatalog's hints for bare tool names, then the tool is
|
|
34
|
+
# treated as a method the agent class defines.
|
|
35
|
+
#
|
|
36
|
+
# Scopes are passed in rather than derived, so the caller's ownership
|
|
37
|
+
# rules (single-user, per-user, or multi-tenant) decide what is visible.
|
|
38
|
+
class ToolDiscovery
|
|
39
|
+
DEFAULT_WINDOW_HOURS = 24 * 7
|
|
40
|
+
MAX_WINDOW_HOURS = 24 * 90
|
|
41
|
+
|
|
42
|
+
# The inventory is derived by reading records rather than by maintaining
|
|
43
|
+
# a summary table, and the window alone does not bound how many rows a
|
|
44
|
+
# busy account has in it — a 90-day window over a high-traffic workspace
|
|
45
|
+
# is millions. Scans stop here and say so rather than pinning a worker
|
|
46
|
+
# for the length of a request. A precomputed summary is the real answer;
|
|
47
|
+
# this keeps the page responsive until there is one.
|
|
48
|
+
MAX_SCAN_ROWS = 20_000
|
|
49
|
+
|
|
50
|
+
# Origin values, matching the framework's ToolOrigin plus the
|
|
51
|
+
# dashboard-only "builtin" bucket for AgentToolbox's executable tools.
|
|
52
|
+
ORIGIN_MCP = "mcp"
|
|
53
|
+
ORIGIN_BUILTIN = "builtin"
|
|
54
|
+
ORIGIN_AGENT = "agent"
|
|
55
|
+
|
|
56
|
+
attr_reader :traces, :agents, :window_hours, :since
|
|
57
|
+
|
|
58
|
+
# @param traces [ActiveRecord::Relation] the traces the caller may read
|
|
59
|
+
# @param agents [ActiveRecord::Relation] the agents the caller may reach
|
|
60
|
+
# @param hours [Integer] how far back to look
|
|
61
|
+
def initialize(traces:, agents:, hours: DEFAULT_WINDOW_HOURS)
|
|
62
|
+
@traces = traces
|
|
63
|
+
@agents = agents
|
|
64
|
+
@window_hours = hours.to_i.clamp(1, MAX_WINDOW_HOURS)
|
|
65
|
+
@since = @window_hours.hours.ago
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The full inventory: every tool seen in the window, plus the MCP servers
|
|
69
|
+
# they roll up into and the configured-but-unused surface.
|
|
70
|
+
#
|
|
71
|
+
# @return [Hash]
|
|
72
|
+
def inventory
|
|
73
|
+
tools = detected_tools
|
|
74
|
+
{
|
|
75
|
+
tools: tools,
|
|
76
|
+
servers: servers_for(tools),
|
|
77
|
+
summary: summary_for(tools),
|
|
78
|
+
window_hours: window_hours,
|
|
79
|
+
sources: source_availability
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Detected tools, most-used first.
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<Hash>]
|
|
86
|
+
def detected_tools
|
|
87
|
+
index = {}
|
|
88
|
+
|
|
89
|
+
merge_declared_tools(index)
|
|
90
|
+
merge_trace_tools(index)
|
|
91
|
+
merge_generation_tools(index)
|
|
92
|
+
merge_message_tools(index)
|
|
93
|
+
merge_configured_tools(index)
|
|
94
|
+
|
|
95
|
+
index.values.map { |entry| finalize(entry) }.sort_by { |tool| [ -tool[:calls], tool[:name] ] }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# MCP servers, detected traffic joined against the catalog so unused
|
|
99
|
+
# defaults are still listed (as +status: "available"+).
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<Hash>]
|
|
102
|
+
def servers(tools = detected_tools)
|
|
103
|
+
servers_for(tools)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Tools AgentToolbox implements inside the dashboard. Neither MCP nor
|
|
107
|
+
# agent-defined — the engine runs them — so they get their own origin
|
|
108
|
+
# rather than being mislabeled as methods on the agent class. Resolved
|
|
109
|
+
# lazily: referencing an autoloaded constant while this class is being
|
|
110
|
+
# defined would bind whatever happened to be loaded first.
|
|
111
|
+
def self.builtin_tools
|
|
112
|
+
@builtin_tools ||= AgentToolbox::DEFINITIONS.values.flatten
|
|
113
|
+
.map { |definition| definition[:name].to_s }.to_set
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# --- sources -------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
# The roster each generation request OFFERED the provider, from the
|
|
121
|
+
# solid_agent side. The trace side is read in merge_trace_tools, which
|
|
122
|
+
# already has each trace loaded.
|
|
123
|
+
def merge_declared_tools(index)
|
|
124
|
+
declared_generations.each do |generation|
|
|
125
|
+
provenance = generation.provenance
|
|
126
|
+
next unless provenance.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
Array(provenance["tools"]).each do |declared|
|
|
129
|
+
next unless declared.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
absorb_declaration(
|
|
132
|
+
index,
|
|
133
|
+
{
|
|
134
|
+
name: declared["name"],
|
|
135
|
+
description: declared["description"],
|
|
136
|
+
parameters: Array(declared["parameters"])
|
|
137
|
+
},
|
|
138
|
+
agent: provenance["agent_class"],
|
|
139
|
+
source: "provenance",
|
|
140
|
+
at: generation.created_at
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def absorb_declaration(index, declared, agent:, source:, at:)
|
|
147
|
+
entry = entry_for(index, declared[:name])
|
|
148
|
+
return if entry.nil?
|
|
149
|
+
|
|
150
|
+
entry[:declared] = true
|
|
151
|
+
entry[:description] ||= declared[:description].presence
|
|
152
|
+
entry[:parameters] = declared[:parameters] if entry[:parameters].blank? && declared[:parameters].present?
|
|
153
|
+
entry[:agents] << agent if agent.present?
|
|
154
|
+
entry[:sources] << source
|
|
155
|
+
apply_origin(entry, **classify(declared[:name]))
|
|
156
|
+
touch(entry, at)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Tool spans from telemetry. Read in Ruby rather than SQL because the
|
|
160
|
+
# spans are a nested JSON array; traversing it in SQL would be a
|
|
161
|
+
# PostgreSQL-only query, and the dashboard runs on SQLite and MySQL too.
|
|
162
|
+
def merge_trace_tools(index)
|
|
163
|
+
scanned = 0
|
|
164
|
+
|
|
165
|
+
traces_scope.find_each(batch_size: 200) do |trace|
|
|
166
|
+
scanned += 1
|
|
167
|
+
break if scanned > MAX_SCAN_ROWS
|
|
168
|
+
|
|
169
|
+
agent_label = trace.agent_class.presence
|
|
170
|
+
|
|
171
|
+
# Same pass, two readings of one trace: what the request offered,
|
|
172
|
+
# and what the model then called.
|
|
173
|
+
trace.declared_tools.each do |declared|
|
|
174
|
+
absorb_declaration(index, declared, agent: agent_label, source: "request", at: trace.timestamp)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
trace.tool_usage.each do |usage|
|
|
178
|
+
entry = entry_for(index, usage[:name])
|
|
179
|
+
next if entry.nil?
|
|
180
|
+
|
|
181
|
+
entry[:calls] += 1
|
|
182
|
+
entry[:errors] += 1 if usage[:status].to_s == "ERROR" || usage[:error].present?
|
|
183
|
+
if (duration = usage[:duration_ms])
|
|
184
|
+
entry[:duration_total] += duration.to_f
|
|
185
|
+
entry[:duration_samples] += 1
|
|
186
|
+
end
|
|
187
|
+
entry[:agents] << agent_label if agent_label
|
|
188
|
+
entry[:sources] << "telemetry"
|
|
189
|
+
entry[:sample_arguments] ||= usage[:arguments].presence
|
|
190
|
+
entry[:last_error] ||= usage[:error].presence
|
|
191
|
+
apply_origin(entry, **resolve_origin(usage[:name], usage[:mcp_server]))
|
|
192
|
+
touch(entry, trace.timestamp)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
warn_if_capped(scanned, "traces")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# A capped scan is a partial inventory, so it is never silent.
|
|
200
|
+
def warn_if_capped(count, what)
|
|
201
|
+
return if count < MAX_SCAN_ROWS
|
|
202
|
+
|
|
203
|
+
Rails.logger.warn(
|
|
204
|
+
"[ActionAgent] tool discovery stopped at #{MAX_SCAN_ROWS} #{what}; " \
|
|
205
|
+
"the inventory for this window may be incomplete"
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Tool calls the model asked for, recorded on each solid_agent
|
|
210
|
+
# generation. Selected columns only — raw_response on these rows can be
|
|
211
|
+
# large and nothing here reads it.
|
|
212
|
+
def merge_generation_tools(index)
|
|
213
|
+
generations_scope.find_each(batch_size: 500) do |generation|
|
|
214
|
+
Array(generation.tool_calls).each do |call|
|
|
215
|
+
name = call_name(call)
|
|
216
|
+
entry = entry_for(index, name)
|
|
217
|
+
next if entry.nil?
|
|
218
|
+
|
|
219
|
+
entry[:requested] += 1
|
|
220
|
+
entry[:sources] << "generations"
|
|
221
|
+
entry[:sample_arguments] ||= stringify_arguments(call["arguments"] || call[:arguments])
|
|
222
|
+
apply_origin(entry, **classify(name))
|
|
223
|
+
touch(entry, generation.created_at)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Tool results persisted as conversation messages. These carry the
|
|
229
|
+
# arguments a tool was actually invoked with, which telemetry truncates.
|
|
230
|
+
def merge_message_tools(index)
|
|
231
|
+
messages_scope.find_each(batch_size: 500) do |message|
|
|
232
|
+
entry = entry_for(index, message.tool_name)
|
|
233
|
+
next if entry.nil?
|
|
234
|
+
|
|
235
|
+
entry[:results] += 1
|
|
236
|
+
entry[:sources] << "messages"
|
|
237
|
+
entry[:sample_arguments] ||= stringify_arguments(message.tool_arguments)
|
|
238
|
+
apply_origin(entry, **classify(message.tool_name))
|
|
239
|
+
touch(entry, message.created_at)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Tools an agent has enabled in the builder but that have no traffic in
|
|
244
|
+
# the window. Listing them keeps the view honest: "configured, never
|
|
245
|
+
# called" is a real and useful state, and it's the difference between a
|
|
246
|
+
# tool that is broken and one that simply isn't wired up yet.
|
|
247
|
+
def merge_configured_tools(index)
|
|
248
|
+
agents.find_each(batch_size: 200) do |agent|
|
|
249
|
+
Array(agent.tools).each do |tool_name|
|
|
250
|
+
# An agent enables a capability ("playwright"); the model calls
|
|
251
|
+
# the functions that capability exposes. Expand to the real tool
|
|
252
|
+
# names so configured and detected rows describe the same things.
|
|
253
|
+
expand_configured(tool_name).each do |name|
|
|
254
|
+
entry = entry_for(index, name)
|
|
255
|
+
next if entry.nil?
|
|
256
|
+
|
|
257
|
+
entry[:configured_by] << agent.name
|
|
258
|
+
apply_origin(entry, **classify(name))
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
Array(agent.mcp_servers).each do |server|
|
|
263
|
+
key = mcp_server_key(server)
|
|
264
|
+
next if key.blank?
|
|
265
|
+
|
|
266
|
+
configured_servers[key] << agent.name
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# --- scopes --------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def traces_scope
|
|
274
|
+
traces.where(timestamp: since..)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def generations_scope
|
|
278
|
+
AgentGeneration
|
|
279
|
+
.select(:id, :agent_context_id, :tool_calls, :created_at)
|
|
280
|
+
.where(agent_context_id: context_ids, created_at: since..)
|
|
281
|
+
.where(AgentGeneration.json_array_not_empty_sql(:tool_calls))
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def messages_scope
|
|
285
|
+
AgentMessage
|
|
286
|
+
.select(:id, :agent_context_id, :tool_name, :tool_arguments, :created_at)
|
|
287
|
+
.where(agent_context_id: context_ids, created_at: since.., role: "tool")
|
|
288
|
+
.where.not(tool_name: nil)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Generations whose provenance carries the offered tool roster.
|
|
292
|
+
#
|
|
293
|
+
# PostgreSQL can ask that of the column directly; everywhere else the
|
|
294
|
+
# rows are read and filtered in Ruby, which is why this returns records
|
|
295
|
+
# rather than a relation. The window already bounds how many.
|
|
296
|
+
def declared_generations
|
|
297
|
+
@declared_generations ||= begin
|
|
298
|
+
scope = AgentGeneration
|
|
299
|
+
.select(:id, :agent_context_id, :provenance, :created_at)
|
|
300
|
+
.where(agent_context_id: context_ids, created_at: since..)
|
|
301
|
+
|
|
302
|
+
rows =
|
|
303
|
+
if AgentGeneration.postgres?
|
|
304
|
+
scope.where(Arel.sql("jsonb_exists(provenance, 'tools')")).limit(MAX_SCAN_ROWS).to_a
|
|
305
|
+
else
|
|
306
|
+
# No portable way to ask this of the column, so the rows are read
|
|
307
|
+
# and filtered in Ruby. Capped, because the window alone does not
|
|
308
|
+
# bound how many a busy account has.
|
|
309
|
+
scope.limit(MAX_SCAN_ROWS).to_a.select do |generation|
|
|
310
|
+
generation.provenance.is_a?(Hash) && generation.provenance.key?("tools")
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
warn_if_capped(rows.length, "provenance generations")
|
|
315
|
+
rows
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Whether any generation in the window carries an offered tool roster,
|
|
320
|
+
# asked as cheaply as the adapter allows. Kept separate from
|
|
321
|
+
# declared_generations so source_availability never materialises rows
|
|
322
|
+
# just to decide a boolean.
|
|
323
|
+
def declared_generations?
|
|
324
|
+
scope = AgentGeneration.where(agent_context_id: context_ids, created_at: since..)
|
|
325
|
+
|
|
326
|
+
if AgentGeneration.postgres?
|
|
327
|
+
scope.where(Arel.sql("jsonb_exists(provenance, 'tools')")).limit(1).exists?
|
|
328
|
+
else
|
|
329
|
+
declared_generations.any?
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def context_ids
|
|
334
|
+
@context_ids ||= AgentContext.for_agents(agents).pluck(:id)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Which record sources actually had rows in the window. The view uses
|
|
338
|
+
# this to explain an empty inventory ("no telemetry reported yet")
|
|
339
|
+
# instead of showing a bare zero.
|
|
340
|
+
def source_availability
|
|
341
|
+
{
|
|
342
|
+
telemetry: traces_scope.limit(1).exists?,
|
|
343
|
+
generations: generations_scope.limit(1).exists?,
|
|
344
|
+
messages: messages_scope.limit(1).exists?,
|
|
345
|
+
declared: declared_generations?
|
|
346
|
+
}
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# --- aggregation ---------------------------------------------------
|
|
350
|
+
|
|
351
|
+
def entry_for(index, name)
|
|
352
|
+
name = name.to_s.strip
|
|
353
|
+
return nil if name.blank?
|
|
354
|
+
|
|
355
|
+
index[name] ||= {
|
|
356
|
+
name: name,
|
|
357
|
+
calls: 0,
|
|
358
|
+
requested: 0,
|
|
359
|
+
results: 0,
|
|
360
|
+
errors: 0,
|
|
361
|
+
duration_total: 0.0,
|
|
362
|
+
duration_samples: 0,
|
|
363
|
+
agents: Set.new,
|
|
364
|
+
sources: Set.new,
|
|
365
|
+
configured_by: Set.new,
|
|
366
|
+
origin: nil,
|
|
367
|
+
mcp_server: nil,
|
|
368
|
+
sample_arguments: nil,
|
|
369
|
+
last_error: nil,
|
|
370
|
+
declared: false,
|
|
371
|
+
description: nil,
|
|
372
|
+
parameters: [],
|
|
373
|
+
first_seen: nil,
|
|
374
|
+
last_seen: nil
|
|
375
|
+
}
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# An explicit MCP server always wins: it came from the trace itself. A
|
|
379
|
+
# catalog hint only fills a gap, and never downgrades an already-known
|
|
380
|
+
# origin (a tool seen once as namespaced stays MCP even if a later bare
|
|
381
|
+
# call for the same name arrives).
|
|
382
|
+
def apply_origin(entry, origin:, server: nil)
|
|
383
|
+
if server.present?
|
|
384
|
+
entry[:mcp_server] ||= server
|
|
385
|
+
entry[:origin] = ORIGIN_MCP
|
|
386
|
+
return
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
entry[:origin] ||= origin
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Origin for a tool a trace reported. A server named by the trace is
|
|
393
|
+
# taken at face value; anything else is re-classified here, because a
|
|
394
|
+
# trace can only tell MCP from not-MCP and knows nothing about the
|
|
395
|
+
# dashboard's own toolbox.
|
|
396
|
+
def resolve_origin(name, explicit_server)
|
|
397
|
+
return { origin: ORIGIN_MCP, server: explicit_server } if explicit_server.present?
|
|
398
|
+
|
|
399
|
+
classify(name)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def classify(name)
|
|
403
|
+
name = name.to_s
|
|
404
|
+
classification = ActiveAgent::Telemetry::ToolOrigin.classify(name)
|
|
405
|
+
return { origin: ORIGIN_MCP, server: classification[:server] } if classification[:server].present?
|
|
406
|
+
|
|
407
|
+
if (hinted = McpCatalog.server_for_tool(name))
|
|
408
|
+
# A catalog hint is weaker evidence than a namespaced name: the tool
|
|
409
|
+
# is *probably* this server's, but a builtin of the same name is the
|
|
410
|
+
# dashboard's own implementation, so builtins win the tie.
|
|
411
|
+
return { origin: ORIGIN_BUILTIN, server: nil } if builtin?(name)
|
|
412
|
+
|
|
413
|
+
return { origin: ORIGIN_MCP, server: hinted }
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
return { origin: ORIGIN_BUILTIN, server: nil } if builtin?(name)
|
|
417
|
+
|
|
418
|
+
{ origin: ORIGIN_AGENT, server: nil }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def builtin?(name)
|
|
422
|
+
self.class.builtin_tools.include?(name)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def touch(entry, timestamp)
|
|
426
|
+
return if timestamp.blank?
|
|
427
|
+
|
|
428
|
+
entry[:first_seen] = timestamp if entry[:first_seen].nil? || timestamp < entry[:first_seen]
|
|
429
|
+
entry[:last_seen] = timestamp if entry[:last_seen].nil? || timestamp > entry[:last_seen]
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def finalize(entry)
|
|
433
|
+
# Telemetry is the authoritative call count, but an install with
|
|
434
|
+
# telemetry disabled would otherwise report every tool as 0 calls
|
|
435
|
+
# while plainly showing results. Fall back to the strongest signal.
|
|
436
|
+
calls = [ entry[:calls], entry[:requested], entry[:results] ].max
|
|
437
|
+
origin = entry[:origin] || ORIGIN_AGENT
|
|
438
|
+
|
|
439
|
+
{
|
|
440
|
+
name: entry[:name],
|
|
441
|
+
base_name: base_name(entry[:name]),
|
|
442
|
+
origin: origin,
|
|
443
|
+
mcp_server: entry[:mcp_server],
|
|
444
|
+
source_label: source_label(origin, entry[:mcp_server]),
|
|
445
|
+
description: entry[:description],
|
|
446
|
+
parameters: entry[:parameters],
|
|
447
|
+
# Offered to the model in a generation request body, as opposed to
|
|
448
|
+
# only ever inferred from a call that happened.
|
|
449
|
+
declared: entry[:declared],
|
|
450
|
+
calls: calls,
|
|
451
|
+
traced_calls: entry[:calls],
|
|
452
|
+
requested: entry[:requested],
|
|
453
|
+
results: entry[:results],
|
|
454
|
+
errors: entry[:errors],
|
|
455
|
+
error_rate: calls.positive? ? (entry[:errors].to_f / calls * 100).round(1) : 0.0,
|
|
456
|
+
avg_duration_ms: entry[:duration_samples].positive? ? (entry[:duration_total] / entry[:duration_samples]).round(0) : nil,
|
|
457
|
+
agents: entry[:agents].to_a.sort,
|
|
458
|
+
configured_by: entry[:configured_by].to_a.sort,
|
|
459
|
+
detected_from: entry[:sources].to_a.sort,
|
|
460
|
+
# Wired up but never called — the state that tells you a tool is
|
|
461
|
+
# available and simply unused, rather than missing. A declared tool
|
|
462
|
+
# counts here too: the model was offered it and never reached for it.
|
|
463
|
+
unused: calls.zero? && (entry[:configured_by].any? || entry[:declared]),
|
|
464
|
+
sample_arguments: entry[:sample_arguments],
|
|
465
|
+
last_error: entry[:last_error],
|
|
466
|
+
first_seen: entry[:first_seen]&.iso8601,
|
|
467
|
+
last_seen: entry[:last_seen]&.iso8601
|
|
468
|
+
}
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def base_name(name)
|
|
472
|
+
ActiveAgent::Telemetry::ToolOrigin.classify(name)[:tool]
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def source_label(origin, server)
|
|
476
|
+
case origin
|
|
477
|
+
when ORIGIN_MCP then server.present? ? "MCP · #{McpCatalog.display_name(server)}" : "MCP"
|
|
478
|
+
when ORIGIN_BUILTIN then "Dashboard toolbox"
|
|
479
|
+
else "Agent-defined"
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# --- servers -------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
# Rolls detected tools up into servers, then unions with the catalog so
|
|
486
|
+
# the view lists defaults this install hasn't connected yet.
|
|
487
|
+
def servers_for(tools)
|
|
488
|
+
detected = Hash.new { |hash, key| hash[key] = { tools: [], calls: 0, errors: 0, last_seen: nil } }
|
|
489
|
+
|
|
490
|
+
tools.each do |tool|
|
|
491
|
+
key = tool[:mcp_server]
|
|
492
|
+
next if key.blank?
|
|
493
|
+
|
|
494
|
+
bucket = detected[key]
|
|
495
|
+
bucket[:tools] << tool
|
|
496
|
+
bucket[:calls] += tool[:calls]
|
|
497
|
+
bucket[:errors] += tool[:errors]
|
|
498
|
+
if tool[:last_seen].present? && (bucket[:last_seen].nil? || tool[:last_seen] > bucket[:last_seen])
|
|
499
|
+
bucket[:last_seen] = tool[:last_seen]
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
keys = (McpCatalog::BY_KEY.keys + detected.keys + configured_servers.keys).uniq
|
|
504
|
+
|
|
505
|
+
# detected has a default block that would materialize a bucket on
|
|
506
|
+
# lookup, so unseen servers are passed through as an explicit nil.
|
|
507
|
+
keys.map { |key| server_row(key, (detected[key] if detected.key?(key))) }
|
|
508
|
+
.sort_by { |server| [ -server[:calls], server[:status] == "available" ? 1 : 0, server[:name].downcase ] }
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def server_row(key, bucket)
|
|
512
|
+
catalog = McpCatalog.find(key)
|
|
513
|
+
configured = configured_servers[key].to_a.sort
|
|
514
|
+
calls = bucket ? bucket[:calls] : 0
|
|
515
|
+
|
|
516
|
+
{
|
|
517
|
+
key: key,
|
|
518
|
+
name: catalog ? catalog[:name] : key,
|
|
519
|
+
description: catalog&.fetch(:description, nil),
|
|
520
|
+
transport: catalog&.fetch(:transport, nil),
|
|
521
|
+
command: catalog&.fetch(:command, nil),
|
|
522
|
+
url: catalog&.fetch(:url, nil),
|
|
523
|
+
categories: catalog ? catalog[:categories] : [],
|
|
524
|
+
docs_url: catalog&.fetch(:docs_url, nil),
|
|
525
|
+
first_party: catalog ? catalog[:first_party] : false,
|
|
526
|
+
requires_credentials: catalog ? catalog[:requires_credentials] : [],
|
|
527
|
+
launchable: catalog ? catalog[:sandbox] : false,
|
|
528
|
+
sandbox_type: catalog&.fetch(:sandbox_type, nil),
|
|
529
|
+
# Catalog membership is what "known" means — a server detected purely
|
|
530
|
+
# from traffic is real but undocumented here, and the view says so.
|
|
531
|
+
known: !catalog.nil?,
|
|
532
|
+
status: server_status(calls, configured, catalog),
|
|
533
|
+
calls: calls,
|
|
534
|
+
errors: bucket ? bucket[:errors] : 0,
|
|
535
|
+
tool_count: bucket ? bucket[:tools].size : 0,
|
|
536
|
+
tools: bucket ? bucket[:tools].map { |tool| tool[:base_name] }.uniq.sort : catalog_tool_names(catalog),
|
|
537
|
+
agents: bucket ? bucket[:tools].flat_map { |tool| tool[:agents] }.uniq.sort : [],
|
|
538
|
+
configured_by: configured,
|
|
539
|
+
last_seen: bucket ? bucket[:last_seen] : nil
|
|
540
|
+
}
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# "active" — traffic in the window
|
|
544
|
+
# "configured"— an agent declares it, but nothing called it yet
|
|
545
|
+
# "available" — a catalog default this install has never used
|
|
546
|
+
def server_status(calls, configured, catalog)
|
|
547
|
+
return "active" if calls.positive?
|
|
548
|
+
return "configured" if configured.any?
|
|
549
|
+
return "available" if catalog
|
|
550
|
+
|
|
551
|
+
"idle"
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def catalog_tool_names(catalog)
|
|
555
|
+
catalog ? catalog[:tools] : []
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def configured_servers
|
|
559
|
+
@configured_servers ||= Hash.new { |hash, key| hash[key] = Set.new }
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# An agent's mcp_servers entries are free-form: a bare string name, or a
|
|
563
|
+
# hash from the builder ({"name" => "playwright", "url" => ...}).
|
|
564
|
+
def mcp_server_key(server)
|
|
565
|
+
return server.to_s.strip if server.is_a?(String)
|
|
566
|
+
return nil unless server.respond_to?(:[])
|
|
567
|
+
|
|
568
|
+
(server["key"] || server[:key] || server["name"] || server[:name]).to_s.strip.presence
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# --- summary -------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
def summary_for(tools)
|
|
574
|
+
called = tools.select { |tool| tool[:calls].positive? }
|
|
575
|
+
total_calls = called.sum { |tool| tool[:calls] }
|
|
576
|
+
total_errors = called.sum { |tool| tool[:errors] }
|
|
577
|
+
timed = called.filter_map { |tool| tool[:avg_duration_ms] }
|
|
578
|
+
|
|
579
|
+
{
|
|
580
|
+
total_tools: tools.size,
|
|
581
|
+
active_tools: called.size,
|
|
582
|
+
unused_tools: tools.count { |tool| tool[:unused] },
|
|
583
|
+
declared_tools: tools.count { |tool| tool[:declared] },
|
|
584
|
+
mcp_tools: tools.count { |tool| tool[:origin] == ORIGIN_MCP },
|
|
585
|
+
total_calls: total_calls,
|
|
586
|
+
total_errors: total_errors,
|
|
587
|
+
error_rate: total_calls.positive? ? (total_errors.to_f / total_calls * 100).round(1) : 0.0,
|
|
588
|
+
avg_duration_ms: timed.any? ? (timed.sum / timed.size).round(0) : nil,
|
|
589
|
+
mcp_servers_active: tools.filter_map { |tool| tool[:mcp_server] if tool[:calls].positive? }.uniq.size
|
|
590
|
+
}
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def call_name(call)
|
|
594
|
+
return nil unless call.respond_to?(:[])
|
|
595
|
+
|
|
596
|
+
(call["name"] || call[:name] || call.dig("function", "name")).to_s.presence
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def stringify_arguments(arguments)
|
|
600
|
+
return nil if arguments.blank?
|
|
601
|
+
return arguments if arguments.is_a?(String)
|
|
602
|
+
|
|
603
|
+
arguments.to_json
|
|
604
|
+
rescue StandardError
|
|
605
|
+
nil
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# A builder capability maps to the function names it exposes; anything
|
|
609
|
+
# not in the toolbox is passed through under its own name.
|
|
610
|
+
def expand_configured(capability)
|
|
611
|
+
definitions = AgentToolbox::DEFINITIONS[capability.to_s]
|
|
612
|
+
return [ capability.to_s ] if definitions.blank?
|
|
613
|
+
|
|
614
|
+
definitions.map { |definition| definition[:name].to_s }
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
end
|