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,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
@@ -0,0 +1,5 @@
1
+ <%#
2
+ The React dashboard's mount point. Initial state rides along as a JSON data
3
+ attribute — no Inertia, no framework requirement on the host app.
4
+ %>
5
+ <div id="active-agent-dashboard" data-props="<%= props.to_json %>"></div>