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,372 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
class SessionRecordingsController < BaseController
|
|
6
|
+
# Every action authenticates. Recordings carry a replayable timeline of
|
|
7
|
+
# a real browser session — including values typed into forms — so the
|
|
8
|
+
# lander-analytics exemption that used to cover start_user_session,
|
|
9
|
+
# record_action, complete_session and demo published exactly the data
|
|
10
|
+
# that most needs a login. A marketing site that wants to record its
|
|
11
|
+
# own visitors should do so against its own endpoint, not one the
|
|
12
|
+
# engine exposes on every host that mounts it.
|
|
13
|
+
|
|
14
|
+
before_action :set_recording, only: [ :show, :actions, :snapshot, :export, :handoff ]
|
|
15
|
+
|
|
16
|
+
# GET /api/session_recordings
|
|
17
|
+
# List recordings with optional filters
|
|
18
|
+
def index
|
|
19
|
+
# Recordings the caller owns, plus the shared demo. Ownership is a
|
|
20
|
+
# real column rather than a JSON metadata key, so this works on
|
|
21
|
+
# every adapter.
|
|
22
|
+
recordings = owned(SessionRecording).or(SessionRecording.where(name: "lander_demo")).recent
|
|
23
|
+
|
|
24
|
+
# Filter by status
|
|
25
|
+
recordings = recordings.where(status: params[:status]) if params[:status].present?
|
|
26
|
+
|
|
27
|
+
# Filter by agent
|
|
28
|
+
if params[:agent_id].present?
|
|
29
|
+
recordings = recordings.joins(:agent_run)
|
|
30
|
+
.where(agent_runs: { agent_id: params[:agent_id] })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Filter by sandbox session
|
|
34
|
+
if params[:sandbox_session_id].present?
|
|
35
|
+
recordings = recordings.where(sandbox_session_id: params[:sandbox_session_id])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Pagination
|
|
39
|
+
page = (params[:page] || 1).to_i
|
|
40
|
+
per_page = [ (params[:per_page] || 20).to_i, 100 ].min
|
|
41
|
+
offset = (page - 1) * per_page
|
|
42
|
+
|
|
43
|
+
total = recordings.count
|
|
44
|
+
recordings = recordings.offset(offset).limit(per_page)
|
|
45
|
+
|
|
46
|
+
render json: {
|
|
47
|
+
recordings: recordings.map { |r| recording_summary(r) },
|
|
48
|
+
pagination: {
|
|
49
|
+
page: page,
|
|
50
|
+
per_page: per_page,
|
|
51
|
+
total: total,
|
|
52
|
+
total_pages: (total.to_f / per_page).ceil
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# GET /api/session_recordings/recent
|
|
58
|
+
# Get recent recordings for the current user
|
|
59
|
+
def recent
|
|
60
|
+
recordings = owned(SessionRecording).recent.limit(10)
|
|
61
|
+
|
|
62
|
+
render json: {
|
|
63
|
+
recordings: recordings.map { |r| recording_summary(r) }
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# GET /api/session_recordings/:id
|
|
68
|
+
# Get full recording details for playback
|
|
69
|
+
def show
|
|
70
|
+
render json: {
|
|
71
|
+
recording: recording_detail(@recording)
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# GET /api/session_recordings/:id/actions
|
|
76
|
+
# Get the action timeline for playback
|
|
77
|
+
def actions
|
|
78
|
+
actions = @recording.recording_actions.ordered
|
|
79
|
+
|
|
80
|
+
# Support pagination for large recordings
|
|
81
|
+
if params[:after_sequence].present?
|
|
82
|
+
actions = actions.where("sequence > ?", params[:after_sequence].to_i)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
limit = [ params[:limit]&.to_i || 100, 500 ].min
|
|
86
|
+
actions = actions.limit(limit)
|
|
87
|
+
|
|
88
|
+
render json: {
|
|
89
|
+
actions: actions.map(&:as_json_for_api),
|
|
90
|
+
has_more: actions.count == limit,
|
|
91
|
+
total_actions: @recording.action_count
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# GET /api/session_recordings/:id/snapshot/:action_id
|
|
96
|
+
# Get a specific snapshot (screenshot or DOM)
|
|
97
|
+
def snapshot
|
|
98
|
+
action = @recording.recording_actions.find(params[:action_id])
|
|
99
|
+
|
|
100
|
+
snapshot_type = params[:type] || "screenshot"
|
|
101
|
+
|
|
102
|
+
case snapshot_type
|
|
103
|
+
when "screenshot"
|
|
104
|
+
url = action.screenshot_url
|
|
105
|
+
render json: { url: url, type: "screenshot" }
|
|
106
|
+
when "dom"
|
|
107
|
+
content = action.dom_snapshot_content
|
|
108
|
+
render json: { content: content, type: "dom" }
|
|
109
|
+
else
|
|
110
|
+
render json: { error: "Unknown snapshot type" }, status: :bad_request
|
|
111
|
+
end
|
|
112
|
+
rescue ActiveRecord::RecordNotFound
|
|
113
|
+
render json: { error: "Action not found" }, status: :not_found
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# POST /api/session_recordings/:id/export
|
|
117
|
+
# Export recording as VCR cassette
|
|
118
|
+
def export
|
|
119
|
+
format = params[:format] || "json"
|
|
120
|
+
|
|
121
|
+
cassette = build_cassette(@recording, format)
|
|
122
|
+
|
|
123
|
+
render json: {
|
|
124
|
+
cassette: cassette,
|
|
125
|
+
filename: "#{@recording.name}_recording.#{format}"
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# POST /api/session_recordings/start_user_session
|
|
130
|
+
# Start a new user takeover session for analytics
|
|
131
|
+
def start_user_session
|
|
132
|
+
recording = SessionRecording.start_user_session!(
|
|
133
|
+
visitor_id: params[:visitor_id] || generate_visitor_id,
|
|
134
|
+
parent_demo_id: params[:parent_demo_id],
|
|
135
|
+
page_url: params[:page_url]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Set user agent from request
|
|
139
|
+
recording.update!(
|
|
140
|
+
metadata: recording.metadata.merge(
|
|
141
|
+
user_agent: request.user_agent,
|
|
142
|
+
ip_hash: Digest::SHA256.hexdigest(request.remote_ip.to_s)[0..16]
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Record the handoff action
|
|
147
|
+
recording.record_action!(
|
|
148
|
+
action_type: "handoff",
|
|
149
|
+
value: "User took over from agent demo",
|
|
150
|
+
metadata: {
|
|
151
|
+
source: "lander_demo",
|
|
152
|
+
step: params[:step] || 4
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
render json: {
|
|
157
|
+
recording_id: recording.id,
|
|
158
|
+
visitor_id: recording.metadata["visitor_id"],
|
|
159
|
+
message: "User session started"
|
|
160
|
+
}, status: :created
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# POST /api/session_recordings/:id/record_action
|
|
164
|
+
# Record a user action in an active session
|
|
165
|
+
def record_action
|
|
166
|
+
recording = SessionRecording.find(params[:id])
|
|
167
|
+
return not_found unless can_manage_recording?(recording)
|
|
168
|
+
|
|
169
|
+
unless recording.recording?
|
|
170
|
+
render json: { error: "Recording already completed" }, status: :unprocessable_entity
|
|
171
|
+
return
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
action = recording.record_action!(
|
|
175
|
+
action_type: params[:action_type],
|
|
176
|
+
selector: params[:selector],
|
|
177
|
+
value: params[:value],
|
|
178
|
+
metadata: params[:metadata]&.to_unsafe_h || {}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
render json: {
|
|
182
|
+
action_id: action.id,
|
|
183
|
+
sequence: action.sequence,
|
|
184
|
+
timestamp_ms: action.timestamp_ms
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# POST /api/session_recordings/:id/complete
|
|
189
|
+
# Complete a user session recording
|
|
190
|
+
def complete_session
|
|
191
|
+
recording = SessionRecording.find(params[:id])
|
|
192
|
+
return not_found unless can_manage_recording?(recording)
|
|
193
|
+
|
|
194
|
+
unless recording.recording?
|
|
195
|
+
render json: { error: "Recording already completed" }, status: :unprocessable_entity
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Record the completion action
|
|
200
|
+
recording.record_action!(
|
|
201
|
+
action_type: "completion",
|
|
202
|
+
value: params[:completion_type] || "session_end",
|
|
203
|
+
metadata: {
|
|
204
|
+
email_submitted: params[:email_submitted],
|
|
205
|
+
success: params[:success]
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
recording.complete!
|
|
210
|
+
|
|
211
|
+
render json: {
|
|
212
|
+
recording_id: recording.id,
|
|
213
|
+
status: recording.status,
|
|
214
|
+
action_count: recording.action_count,
|
|
215
|
+
duration_ms: recording.duration_ms
|
|
216
|
+
}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# POST /api/session_recordings/:id/handoff
|
|
220
|
+
# Get handoff state to continue where agent left off
|
|
221
|
+
def handoff
|
|
222
|
+
handoff_state = @recording.metadata["handoff_state"]
|
|
223
|
+
|
|
224
|
+
unless handoff_state
|
|
225
|
+
render json: { error: "No handoff state available" }, status: :unprocessable_entity
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Create a new recording for the user's continuation
|
|
230
|
+
continuation = SessionRecording.create!(
|
|
231
|
+
sandbox_session: @recording.sandbox_session,
|
|
232
|
+
agent_run: @recording.agent_run,
|
|
233
|
+
name: "#{@recording.name}_continuation",
|
|
234
|
+
status: :recording,
|
|
235
|
+
metadata: {
|
|
236
|
+
parent_recording_id: @recording.id,
|
|
237
|
+
handoff_from: @recording.action_count,
|
|
238
|
+
started_at: Time.current.iso8601,
|
|
239
|
+
user_id: current_user&.id
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
render json: {
|
|
244
|
+
handoff_state: handoff_state,
|
|
245
|
+
continuation_recording_id: continuation.id,
|
|
246
|
+
parent_recording: recording_summary(@recording),
|
|
247
|
+
message: "Ready to continue from action #{@recording.action_count}"
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# DELETE /api/session_recordings/:id
|
|
252
|
+
def destroy
|
|
253
|
+
@recording = SessionRecording.find(params[:id])
|
|
254
|
+
|
|
255
|
+
# Only allow deletion of own recordings (or any if admin)
|
|
256
|
+
unless can_manage_recording?(@recording)
|
|
257
|
+
render json: { error: "Not authorized" }, status: :forbidden
|
|
258
|
+
return
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
@recording.destroy!
|
|
262
|
+
render json: { message: "Recording deleted" }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
# Scoped through can_manage_recording? rather than owned(): nothing in
|
|
268
|
+
# the engine writes user_id/account_id onto a recording, so an
|
|
269
|
+
# ownership scope would hide it from the person who made it. 404 rather
|
|
270
|
+
# than 403 so ids stay unenumerable.
|
|
271
|
+
def set_recording
|
|
272
|
+
@recording = SessionRecording.find(params[:id])
|
|
273
|
+
return if can_manage_recording?(@recording)
|
|
274
|
+
|
|
275
|
+
not_found
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def can_manage_recording?(recording)
|
|
279
|
+
# An install with no owner model owns everything it can see.
|
|
280
|
+
return true if SessionRecording.owner_association.nil?
|
|
281
|
+
return true if owned(SessionRecording).exists?(id: recording.id)
|
|
282
|
+
|
|
283
|
+
# Recordings made inside a sandbox belong to whoever opened it.
|
|
284
|
+
session = recording.sandbox_session
|
|
285
|
+
return true if session && session.owner.present? && session.owner == current_owner
|
|
286
|
+
|
|
287
|
+
false
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def recording_summary(recording)
|
|
291
|
+
{
|
|
292
|
+
id: recording.id,
|
|
293
|
+
name: recording.name,
|
|
294
|
+
status: recording.status,
|
|
295
|
+
action_count: recording.action_count,
|
|
296
|
+
duration_ms: recording.duration_ms,
|
|
297
|
+
created_at: recording.created_at.iso8601,
|
|
298
|
+
thumbnail_url: first_screenshot_url(recording),
|
|
299
|
+
agent_name: recording.agent_run&.agent&.name,
|
|
300
|
+
sandbox_type: recording.sandbox_session&.sandbox_type
|
|
301
|
+
}
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def recording_detail(recording)
|
|
305
|
+
{
|
|
306
|
+
id: recording.id,
|
|
307
|
+
name: recording.name,
|
|
308
|
+
status: recording.status,
|
|
309
|
+
action_count: recording.action_count,
|
|
310
|
+
duration_ms: recording.duration_ms,
|
|
311
|
+
metadata: safe_metadata(recording.metadata),
|
|
312
|
+
created_at: recording.created_at.iso8601,
|
|
313
|
+
updated_at: recording.updated_at.iso8601,
|
|
314
|
+
timeline: recording.timeline,
|
|
315
|
+
handoff_state: recording.metadata["handoff_state"],
|
|
316
|
+
agent: recording.agent_run&.agent&.slice(:id, :name),
|
|
317
|
+
sandbox_session: recording.sandbox_session&.summary
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def first_screenshot_url(recording)
|
|
322
|
+
action = recording.recording_actions.with_screenshots.first
|
|
323
|
+
action&.screenshot_url(expires_in: 1.hour)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def safe_metadata(metadata)
|
|
327
|
+
# Remove sensitive data from metadata
|
|
328
|
+
metadata.except("cookies", "session_storage", "local_storage")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def generate_visitor_id
|
|
332
|
+
# Generate a stable visitor ID based on IP and user agent
|
|
333
|
+
fingerprint = "#{request.remote_ip}:#{request.user_agent}"
|
|
334
|
+
"v_#{Digest::SHA256.hexdigest(fingerprint)[0..16]}"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def build_cassette(recording, format)
|
|
338
|
+
cassette = {
|
|
339
|
+
name: recording.name,
|
|
340
|
+
recorded_at: recording.created_at.iso8601,
|
|
341
|
+
duration_ms: recording.duration_ms,
|
|
342
|
+
action_count: recording.action_count,
|
|
343
|
+
actions: recording.recording_actions.ordered.map do |action|
|
|
344
|
+
{
|
|
345
|
+
type: action.action_type,
|
|
346
|
+
sequence: action.sequence,
|
|
347
|
+
timestamp_ms: action.timestamp_ms,
|
|
348
|
+
selector: action.selector,
|
|
349
|
+
value: action.value,
|
|
350
|
+
metadata: action.metadata
|
|
351
|
+
}
|
|
352
|
+
end
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
# Include screenshots as base64 if requested
|
|
356
|
+
if params[:include_screenshots] == "true"
|
|
357
|
+
cassette[:actions].each_with_index do |action_data, i|
|
|
358
|
+
action = recording.recording_actions.find_by(sequence: action_data[:sequence])
|
|
359
|
+
if action&.screenshot_key
|
|
360
|
+
snapshot = RecordingSnapshot.find_by(storage_key: action.screenshot_key)
|
|
361
|
+
if snapshot&.file&.attached?
|
|
362
|
+
action_data[:screenshot_base64] = Base64.encode64(snapshot.file.download)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
cassette
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
class TemplatesController < BaseController
|
|
6
|
+
# No anonymous exemption: #show looks a template up by bare id, so the
|
|
7
|
+
# exemption served unpublished drafts and private prompt libraries to
|
|
8
|
+
# anyone walking the id space. #index was already limited to public
|
|
9
|
+
# templates; #show was not.
|
|
10
|
+
|
|
11
|
+
# GET /api/templates
|
|
12
|
+
def index
|
|
13
|
+
@templates = AgentTemplate.public_templates.order(usage_count: :desc)
|
|
14
|
+
|
|
15
|
+
# Filter by category
|
|
16
|
+
@templates = @templates.by_category(params[:category]) if params[:category].present?
|
|
17
|
+
|
|
18
|
+
# Featured only
|
|
19
|
+
@templates = @templates.featured if params[:featured].present?
|
|
20
|
+
|
|
21
|
+
render json: {
|
|
22
|
+
templates: @templates.map { |t| template_json(t) },
|
|
23
|
+
categories: AgentTemplate::CATEGORIES
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# GET /api/templates/:id
|
|
28
|
+
def show
|
|
29
|
+
@template = AgentTemplate.find(params[:id])
|
|
30
|
+
render json: { template: template_json(@template, include_details: true) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# POST /api/templates/:id/use
|
|
34
|
+
def use
|
|
35
|
+
@template = AgentTemplate.find(params[:id])
|
|
36
|
+
|
|
37
|
+
agent = @template.create_agent_for(
|
|
38
|
+
current_user,
|
|
39
|
+
name: params[:name] || @template.name
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if agent.persisted?
|
|
43
|
+
render json: { agent: agent_json(agent) }, status: :created
|
|
44
|
+
else
|
|
45
|
+
render json: { errors: agent.errors.full_messages }, status: :unprocessable_entity
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def template_json(template, include_details: false)
|
|
52
|
+
json = {
|
|
53
|
+
id: template.id,
|
|
54
|
+
name: template.name,
|
|
55
|
+
slug: template.slug,
|
|
56
|
+
description: template.description,
|
|
57
|
+
category: template.category,
|
|
58
|
+
provider: template.provider,
|
|
59
|
+
model: template.model,
|
|
60
|
+
preset_type: template.preset_type,
|
|
61
|
+
appearance: template.appearance,
|
|
62
|
+
icon: template.icon,
|
|
63
|
+
usage_count: template.usage_count,
|
|
64
|
+
featured: template.featured,
|
|
65
|
+
tools: template.tools
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if include_details
|
|
69
|
+
json.merge!(
|
|
70
|
+
instructions: template.instructions,
|
|
71
|
+
instruction_sets: template.instruction_sets,
|
|
72
|
+
model_config: template.model_config
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
json
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def agent_json(agent)
|
|
80
|
+
{
|
|
81
|
+
id: agent.id,
|
|
82
|
+
name: agent.name,
|
|
83
|
+
slug: agent.slug,
|
|
84
|
+
description: agent.description,
|
|
85
|
+
provider: agent.provider,
|
|
86
|
+
model: agent.model,
|
|
87
|
+
status: agent.status,
|
|
88
|
+
preset_type: agent.preset_type,
|
|
89
|
+
appearance: agent.appearance
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# Read API for the tool inventory, backing the dashboard Tools view.
|
|
6
|
+
#
|
|
7
|
+
# Nothing here is registered by hand: ToolDiscovery derives the
|
|
8
|
+
# inventory from the tool roster each generation request offered, from
|
|
9
|
+
# telemetry tool spans, and from solid_agent generation/message records,
|
|
10
|
+
# then unions it with the tools each agent has enabled in the builder so
|
|
11
|
+
# configured-but-unused tools are visible too.
|
|
12
|
+
class ToolsController < BaseController
|
|
13
|
+
before_action :require_owner!
|
|
14
|
+
|
|
15
|
+
# Filter labels for the view's origin tabs, so the set of buckets is
|
|
16
|
+
# defined server-side alongside the classification that produces them.
|
|
17
|
+
ORIGIN_FILTERS = {
|
|
18
|
+
"all" => "All tools",
|
|
19
|
+
ToolDiscovery::ORIGIN_MCP => "MCP",
|
|
20
|
+
ToolDiscovery::ORIGIN_BUILTIN => "Dashboard",
|
|
21
|
+
ToolDiscovery::ORIGIN_AGENT => "Agent-defined"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# GET /api/tools
|
|
25
|
+
def index
|
|
26
|
+
inventory = discovery.inventory
|
|
27
|
+
|
|
28
|
+
render json: inventory.merge(
|
|
29
|
+
tools: filtered(inventory[:tools]),
|
|
30
|
+
origins: ORIGIN_FILTERS
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def discovery
|
|
37
|
+
ToolDiscovery.new(traces: owned_traces, agents: owner_agents, hours: window_hours)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def filtered(tools)
|
|
41
|
+
if ORIGIN_FILTERS.key?(params[:origin].to_s) && params[:origin] != "all"
|
|
42
|
+
tools = tools.select { |tool| tool[:origin] == params[:origin] }
|
|
43
|
+
end
|
|
44
|
+
tools = tools.select { |tool| tool[:mcp_server] == params[:server] } if params[:server].present?
|
|
45
|
+
|
|
46
|
+
if (query = params[:q].to_s.strip.downcase).present?
|
|
47
|
+
tools = tools.select { |tool| tool[:name].downcase.include?(query) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
tools
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def window_hours
|
|
54
|
+
params.fetch(:hours, ToolDiscovery::DEFAULT_WINDOW_HOURS).to_i
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# Read API for telemetry traces, backing the dashboard's Traces view.
|
|
6
|
+
#
|
|
7
|
+
# Query logic mirrors the server-rendered TracesController (same scopes
|
|
8
|
+
# on ActionAgent.trace_model), scoped to the caller's tenant.
|
|
9
|
+
# Named apart from Api::TracesController because that one is the ingest
|
|
10
|
+
# endpoint: same /api/traces path, different verb and different auth.
|
|
11
|
+
class TraceReportsController < BaseController
|
|
12
|
+
before_action :require_owner!
|
|
13
|
+
|
|
14
|
+
DEFAULT_WINDOW_MINUTES = 60
|
|
15
|
+
MAX_WINDOW_MINUTES = 60 * 24 * 90
|
|
16
|
+
DEFAULT_LIMIT = 500
|
|
17
|
+
|
|
18
|
+
# GET /api/traces
|
|
19
|
+
def index
|
|
20
|
+
window = params.fetch(:minutes, DEFAULT_WINDOW_MINUTES).to_i.clamp(1, MAX_WINDOW_MINUTES)
|
|
21
|
+
window_scope = traces_scope.for_date_range(window.minutes.ago, Time.current)
|
|
22
|
+
|
|
23
|
+
scope = window_scope
|
|
24
|
+
scope = scope.for_agent(params[:agent]) if params[:agent].present?
|
|
25
|
+
scope = scope.for_service(params[:service]) if params[:service].present?
|
|
26
|
+
scope = scope.with_errors if params[:status] == "error"
|
|
27
|
+
|
|
28
|
+
limit = params.fetch(:limit, DEFAULT_LIMIT).to_i.clamp(1, 1000)
|
|
29
|
+
traces = scope.recent.limit(limit)
|
|
30
|
+
|
|
31
|
+
render json: {
|
|
32
|
+
traces: traces.map { |trace| TelemetryTraceSerializer.summary(trace) },
|
|
33
|
+
agents: window_scope.distinct.pluck(:agent_class).compact.sort,
|
|
34
|
+
# agent_class => platform Agent id, so the Traces view can link a
|
|
35
|
+
# trace back to the agent it belongs to (AgentRegistrar attributes
|
|
36
|
+
# SDK-reported traces to an Agent record on ingest).
|
|
37
|
+
agent_ids: agent_ids_by_class(window_scope),
|
|
38
|
+
window_minutes: window
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# GET /api/traces/:id — accepts the record id or the OTel trace_id
|
|
43
|
+
# (full or 8-char short form), so generation refs can deep-link.
|
|
44
|
+
def show
|
|
45
|
+
trace = traces_scope.find_by(trace_id: params[:id]) ||
|
|
46
|
+
traces_scope.where("trace_id LIKE ?", "#{ActionAgent.trace_model.sanitize_sql_like(params[:id].to_s)}%").first ||
|
|
47
|
+
traces_scope.find(params[:id])
|
|
48
|
+
render json: { trace: TelemetryTraceSerializer.detail(trace) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def traces_scope
|
|
54
|
+
ActionAgent.trace_model.for_account(current_account)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# One grouped query. A class can appear under several agent records
|
|
58
|
+
# (same class, different action); the most recently active one wins,
|
|
59
|
+
# since that's what the operator most likely means by "this agent".
|
|
60
|
+
def agent_ids_by_class(scope)
|
|
61
|
+
scope
|
|
62
|
+
.where.not(agent_id: nil, agent_class: nil)
|
|
63
|
+
.group(:agent_class)
|
|
64
|
+
.maximum(:agent_id)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|