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,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
class AgentRunsController < BaseController
|
|
6
|
+
before_action :set_run, only: [ :show, :cancel ]
|
|
7
|
+
|
|
8
|
+
# GET /api/runs/:id
|
|
9
|
+
def show
|
|
10
|
+
render json: {
|
|
11
|
+
run: run_json(@run),
|
|
12
|
+
messages: interaction_messages(@run),
|
|
13
|
+
agent: {
|
|
14
|
+
id: @run.agent.id,
|
|
15
|
+
name: @run.agent.name,
|
|
16
|
+
slug: @run.agent.slug
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# POST /api/runs/:id/cancel
|
|
22
|
+
def cancel
|
|
23
|
+
@run.cancel!
|
|
24
|
+
render json: { run: @run.summary }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# GET /api/runs
|
|
28
|
+
def index
|
|
29
|
+
# Scoped to the caller's own agents: this listed (and counted) every
|
|
30
|
+
# run in the database regardless of who owned it.
|
|
31
|
+
scope = AgentRun.includes(:agent).where(agent: owner_agents).recent
|
|
32
|
+
|
|
33
|
+
scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
|
|
34
|
+
scope = scope.where(status: params[:status]) if params[:status].present?
|
|
35
|
+
|
|
36
|
+
page = (params[:page] || 1).to_i
|
|
37
|
+
per_page = (params[:per_page] || 20).to_i
|
|
38
|
+
total = scope.count
|
|
39
|
+
runs = scope.offset((page - 1) * per_page).limit(per_page)
|
|
40
|
+
|
|
41
|
+
render json: {
|
|
42
|
+
runs: runs.map { |run| run_json(run, include_agent: true) },
|
|
43
|
+
meta: {
|
|
44
|
+
page: page,
|
|
45
|
+
per_page: per_page,
|
|
46
|
+
total: total
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def set_run
|
|
54
|
+
@run = AgentRun.where(agent: owner_agents).find(params[:id])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The run's slice of its agent's conversation stream, for the shared
|
|
58
|
+
# InteractionStream UI. User messages carry the run's trace_id in
|
|
59
|
+
# provenance (SolidAgent::HasContext); the slice spans from this run's
|
|
60
|
+
# user message up to the next user message with a different trace_id.
|
|
61
|
+
# Prepended with the system instructions the run executed under
|
|
62
|
+
# (captured in output_metadata; falls back to the agent's current ones).
|
|
63
|
+
def interaction_messages(run)
|
|
64
|
+
return [] if run.trace_id.blank?
|
|
65
|
+
|
|
66
|
+
context = AgentContext.for_agents(Agent.where(id: run.agent_id)).order(created_at: :desc).first
|
|
67
|
+
return [] unless context
|
|
68
|
+
|
|
69
|
+
messages = context.messages.chronological.to_a
|
|
70
|
+
start_index = messages.index do |message|
|
|
71
|
+
message.role == "user" && message.provenance&.dig("trace_id") == run.trace_id
|
|
72
|
+
end
|
|
73
|
+
return [] unless start_index
|
|
74
|
+
|
|
75
|
+
slice = [ messages[start_index] ]
|
|
76
|
+
messages[(start_index + 1)..].each do |message|
|
|
77
|
+
trace = message.provenance&.dig("trace_id")
|
|
78
|
+
break if message.role == "user" && trace.present? && trace != run.trace_id
|
|
79
|
+
|
|
80
|
+
slice << message
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
serialized = slice.map { |message| AgentMessageSerializer.call(message) }
|
|
84
|
+
instructions = run.output_metadata&.dig("instructions").presence || run.agent.instructions
|
|
85
|
+
if instructions.present?
|
|
86
|
+
serialized.unshift(
|
|
87
|
+
id: "run-#{run.id}-system",
|
|
88
|
+
role: "system",
|
|
89
|
+
content: instructions,
|
|
90
|
+
created_at: (run.started_at || run.created_at).iso8601(3)
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
serialized
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def run_json(run, include_agent: false)
|
|
97
|
+
json = {
|
|
98
|
+
id: run.id,
|
|
99
|
+
status: run.status,
|
|
100
|
+
input_prompt: run.input_prompt,
|
|
101
|
+
input_params: run.input_params,
|
|
102
|
+
output: run.output,
|
|
103
|
+
output_metadata: run.output_metadata,
|
|
104
|
+
duration_ms: run.calculated_duration_ms,
|
|
105
|
+
input_tokens: run.input_tokens,
|
|
106
|
+
output_tokens: run.output_tokens,
|
|
107
|
+
total_tokens: run.total_tokens,
|
|
108
|
+
error_message: run.error_message,
|
|
109
|
+
trace_id: run.trace_id,
|
|
110
|
+
logs: run.logs,
|
|
111
|
+
started_at: run.started_at,
|
|
112
|
+
completed_at: run.completed_at,
|
|
113
|
+
created_at: run.created_at
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if include_agent
|
|
117
|
+
json[:agent] = {
|
|
118
|
+
id: run.agent.id,
|
|
119
|
+
name: run.agent.name,
|
|
120
|
+
slug: run.agent.slug
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
json
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
class AgentsController < BaseController
|
|
6
|
+
# Ranking for the agent cards. Every dimension except "recent" reads the
|
|
7
|
+
# scorecard, which is computed in Ruby over both execution sources, so
|
|
8
|
+
# the ordering is applied there rather than in the SQL scope.
|
|
9
|
+
LIST_SORTS = {
|
|
10
|
+
"recent" => "Recently updated",
|
|
11
|
+
"popular" => "Most runs",
|
|
12
|
+
"longest" => "Longest average",
|
|
13
|
+
"cost" => "Highest cost",
|
|
14
|
+
"tokens" => "Most tokens"
|
|
15
|
+
}.freeze
|
|
16
|
+
DEFAULT_LIST_SORT = "recent"
|
|
17
|
+
|
|
18
|
+
before_action :set_agent, only: [ :show, :update, :destroy, :versions, :runs, :execute, :test, :restore, :duplicate, :export, :analytics ]
|
|
19
|
+
before_action :require_execution_enabled!, only: [ :execute, :test ]
|
|
20
|
+
before_action :require_owner!, only: [ :execute, :test ]
|
|
21
|
+
before_action :enforce_execution_quota!, only: [ :execute, :test ]
|
|
22
|
+
|
|
23
|
+
# GET /api/agents
|
|
24
|
+
def index
|
|
25
|
+
@agents = owner_agents.order(updated_at: :desc)
|
|
26
|
+
|
|
27
|
+
# Filter by status
|
|
28
|
+
@agents = @agents.where(status: params[:status]) if params[:status].present?
|
|
29
|
+
|
|
30
|
+
# Filter by provider
|
|
31
|
+
@agents = @agents.where(provider: params[:provider]) if params[:provider].present?
|
|
32
|
+
|
|
33
|
+
# Search by name
|
|
34
|
+
# LOWER(...) LIKE rather than ILIKE: the dashboard is not
|
|
35
|
+
# PostgreSQL-only.
|
|
36
|
+
if params[:q].present?
|
|
37
|
+
@agents = @agents.where("LOWER(name) LIKE ?", "%#{params[:q].to_s.downcase}%")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
scorecards = AgentScorecard.for_agents(@agents)
|
|
41
|
+
cards = sort_cards(
|
|
42
|
+
@agents.map { |agent| agent_json(agent).merge(stats: scorecards[agent.id]) },
|
|
43
|
+
params[:sort]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
render json: {
|
|
47
|
+
agents: cards,
|
|
48
|
+
meta: {
|
|
49
|
+
total: cards.size,
|
|
50
|
+
sorts: LIST_SORTS,
|
|
51
|
+
sort: list_sort(params[:sort]),
|
|
52
|
+
providers: Agent::PROVIDERS,
|
|
53
|
+
preset_types: Agent::PRESET_TYPES,
|
|
54
|
+
instruction_sets: Agent::INSTRUCTION_SETS,
|
|
55
|
+
available_tools: Agent::AVAILABLE_TOOLS
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# GET /api/agents/:id
|
|
61
|
+
def show
|
|
62
|
+
render json: {
|
|
63
|
+
agent: agent_json(@agent, include_details: true),
|
|
64
|
+
versions: @agent.agent_versions.recent.limit(10).map { |v| version_json(v) },
|
|
65
|
+
recent_runs: @agent.agent_runs.recent.limit(5).map(&:summary)
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# POST /api/agents
|
|
70
|
+
def create
|
|
71
|
+
@agent = owner_agents.build(agent_params)
|
|
72
|
+
|
|
73
|
+
if @agent.save
|
|
74
|
+
render json: { agent: agent_json(@agent, include_details: true) }, status: :created
|
|
75
|
+
else
|
|
76
|
+
render json: { errors: @agent.errors.full_messages }, status: :unprocessable_entity
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# PATCH /api/agents/:id
|
|
81
|
+
def update
|
|
82
|
+
if @agent.update(agent_params)
|
|
83
|
+
render json: { agent: agent_json(@agent, include_details: true) }
|
|
84
|
+
else
|
|
85
|
+
render json: { errors: @agent.errors.full_messages }, status: :unprocessable_entity
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# DELETE /api/agents/:id
|
|
90
|
+
def destroy
|
|
91
|
+
@agent.destroy
|
|
92
|
+
render json: { success: true }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# GET /api/agents/:id/versions
|
|
96
|
+
def versions
|
|
97
|
+
@versions = @agent.agent_versions.recent
|
|
98
|
+
|
|
99
|
+
render json: {
|
|
100
|
+
versions: @versions.map { |v| version_json(v, include_diff: true) }
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# POST /api/agents/:id/restore
|
|
105
|
+
def restore
|
|
106
|
+
version = @agent.agent_versions.find(params[:version_id])
|
|
107
|
+
@agent.restore_from_version!(version)
|
|
108
|
+
|
|
109
|
+
render json: { agent: agent_json(@agent, include_details: true) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# GET /api/agents/:id/runs
|
|
113
|
+
# Every execution of this agent, whoever ran it: dashboard runs and
|
|
114
|
+
# SDK-reported traces in one list, discriminated by `source`. Agents
|
|
115
|
+
# observed from telemetry have no AgentRun rows at all, so a runs-only
|
|
116
|
+
# list showed them as empty while their scorecard reported real traffic.
|
|
117
|
+
def runs
|
|
118
|
+
minutes = params[:minutes].presence&.then { |m| m.to_i.clamp(1, 60 * 24 * 90) }
|
|
119
|
+
page = (params[:page] || 1).to_i
|
|
120
|
+
per_page = (params[:per_page] || 20).to_i
|
|
121
|
+
|
|
122
|
+
executions = AgentExecutions.new(
|
|
123
|
+
agents: [ @agent ],
|
|
124
|
+
owner: current_owner,
|
|
125
|
+
window_minutes: minutes,
|
|
126
|
+
source: params[:source],
|
|
127
|
+
status: params[:status],
|
|
128
|
+
sort: params[:sort]
|
|
129
|
+
).page(page: page, per_page: per_page)
|
|
130
|
+
|
|
131
|
+
# One digest->version map for the page; labels each run's instructions
|
|
132
|
+
# with the agent version that introduced them where one matches.
|
|
133
|
+
digest_versions = @agent.instructions_digest_versions
|
|
134
|
+
runs_by_id = AgentRun.where(id: executions[:rows].select { |r| r.source == "dashboard" }.map(&:id))
|
|
135
|
+
.index_by(&:id)
|
|
136
|
+
|
|
137
|
+
render json: {
|
|
138
|
+
runs: executions[:rows].map { |row| serialize_execution(row, runs_by_id, digest_versions) },
|
|
139
|
+
meta: {
|
|
140
|
+
page: page,
|
|
141
|
+
per_page: per_page,
|
|
142
|
+
total: executions[:total],
|
|
143
|
+
sources: AgentExecutions::SOURCES,
|
|
144
|
+
sorts: AgentExecutions::SORTS
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# POST /api/agents/:id/execute
|
|
150
|
+
def execute
|
|
151
|
+
run = @agent.execute(
|
|
152
|
+
params[:prompt],
|
|
153
|
+
action: params[:action_name],
|
|
154
|
+
**params.fetch(:params, {}).to_unsafe_h.symbolize_keys
|
|
155
|
+
)
|
|
156
|
+
record_execution_usage
|
|
157
|
+
|
|
158
|
+
render json: { run: run.summary }, status: :accepted
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# POST /api/agents/:id/test
|
|
162
|
+
def test
|
|
163
|
+
run = @agent.test_execute(
|
|
164
|
+
params[:prompt],
|
|
165
|
+
action: params[:action_name],
|
|
166
|
+
**params.fetch(:params, {}).to_unsafe_h.symbolize_keys
|
|
167
|
+
)
|
|
168
|
+
record_execution_usage
|
|
169
|
+
|
|
170
|
+
render json: { run: run.summary, output: run.output }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# POST /api/agents/:id/duplicate
|
|
174
|
+
def duplicate
|
|
175
|
+
new_agent = @agent.dup
|
|
176
|
+
new_agent.name = "#{@agent.name} (Copy)"
|
|
177
|
+
new_agent.slug = nil # Will be auto-generated
|
|
178
|
+
new_agent.status = :draft
|
|
179
|
+
new_agent.save!
|
|
180
|
+
|
|
181
|
+
render json: { agent: agent_json(new_agent, include_details: true) }, status: :created
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# GET /api/agents/:id/export
|
|
185
|
+
def export
|
|
186
|
+
render json: {
|
|
187
|
+
agent: agent_json(@agent, include_details: true),
|
|
188
|
+
code: @agent.to_agent_class_code,
|
|
189
|
+
manifest: {
|
|
190
|
+
name: @agent.slug,
|
|
191
|
+
version: "1.0.0",
|
|
192
|
+
model: "#{@agent.provider}/#{@agent.model}",
|
|
193
|
+
description: @agent.description,
|
|
194
|
+
instructions: @agent.instructions,
|
|
195
|
+
tools: @agent.tools,
|
|
196
|
+
config: @agent.model_config
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# GET /api/agents/:id/analytics
|
|
202
|
+
def analytics
|
|
203
|
+
days = (params[:days] || 30).to_i
|
|
204
|
+
start_date = days.days.ago.beginning_of_day
|
|
205
|
+
|
|
206
|
+
runs = @agent.agent_runs.where("created_at >= ?", start_date)
|
|
207
|
+
|
|
208
|
+
# Calculate stats
|
|
209
|
+
total_runs = runs.count
|
|
210
|
+
completed_runs = runs.where(status: :complete).count
|
|
211
|
+
failed_runs = runs.where(status: :failed).count
|
|
212
|
+
avg_duration = runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
|
|
213
|
+
total_tokens = runs.sum(:total_tokens)
|
|
214
|
+
avg_tokens = total_runs > 0 ? (total_tokens.to_f / total_runs).round : 0
|
|
215
|
+
|
|
216
|
+
# Runs by day
|
|
217
|
+
runs_by_day = runs.group("DATE(created_at)")
|
|
218
|
+
.select("DATE(created_at) as date, COUNT(*) as count, SUM(total_tokens) as tokens")
|
|
219
|
+
.order("date")
|
|
220
|
+
.map { |r| { date: r.date.to_s, count: r.count, tokens: r.tokens || 0 } }
|
|
221
|
+
|
|
222
|
+
# Status breakdown
|
|
223
|
+
status_breakdown = runs.group(:status).count.transform_keys(&:to_s)
|
|
224
|
+
|
|
225
|
+
# Recent errors
|
|
226
|
+
recent_errors = runs.failed_runs.recent.limit(5).map do |run|
|
|
227
|
+
{
|
|
228
|
+
id: run.id,
|
|
229
|
+
error: run.error_message&.truncate(200),
|
|
230
|
+
created_at: run.created_at
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
render json: {
|
|
235
|
+
period_days: days,
|
|
236
|
+
summary: {
|
|
237
|
+
total_runs: total_runs,
|
|
238
|
+
completed_runs: completed_runs,
|
|
239
|
+
failed_runs: failed_runs,
|
|
240
|
+
success_rate: total_runs > 0 ? ((completed_runs.to_f / total_runs) * 100).round(1) : 0,
|
|
241
|
+
avg_duration_ms: avg_duration,
|
|
242
|
+
total_tokens: total_tokens,
|
|
243
|
+
avg_tokens_per_run: avg_tokens
|
|
244
|
+
},
|
|
245
|
+
runs_by_day: runs_by_day,
|
|
246
|
+
status_breakdown: status_breakdown,
|
|
247
|
+
recent_errors: recent_errors
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# GET /api/agents/presets
|
|
252
|
+
def presets
|
|
253
|
+
presets = Agent::PRESET_TYPES.map do |preset|
|
|
254
|
+
{
|
|
255
|
+
id: preset,
|
|
256
|
+
name: preset.titleize,
|
|
257
|
+
appearance: default_appearance_for(preset),
|
|
258
|
+
suggested_tools: suggested_tools_for(preset),
|
|
259
|
+
suggested_instructions: suggested_instructions_for(preset)
|
|
260
|
+
}
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
render json: { presets: presets }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
private
|
|
267
|
+
|
|
268
|
+
def list_sort(requested)
|
|
269
|
+
LIST_SORTS.key?(requested.to_s) ? requested.to_s : DEFAULT_LIST_SORT
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Descending on the chosen dimension. Agents with nothing to rank (no
|
|
273
|
+
# runs, no priced spend) sort last instead of interleaving as zeroes,
|
|
274
|
+
# and run count breaks ties.
|
|
275
|
+
def sort_cards(cards, requested)
|
|
276
|
+
sort = list_sort(requested)
|
|
277
|
+
return cards if sort == "recent"
|
|
278
|
+
|
|
279
|
+
key = { "popular" => :runs, "longest" => :avg_duration_ms, "cost" => :cost, "tokens" => :tokens }[sort]
|
|
280
|
+
|
|
281
|
+
cards.sort_by do |card|
|
|
282
|
+
value = card[:stats]&.dig(key)
|
|
283
|
+
[ value.nil? ? 1 : 0, -value.to_f, -card[:stats].to_h[:runs].to_i ]
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Dashboard runs keep their full summary (logs, previews, instructions
|
|
288
|
+
# version); reported executions carry only what a trace knows. `source`
|
|
289
|
+
# tells the UI which it is holding.
|
|
290
|
+
def serialize_execution(row, runs_by_id, digest_versions)
|
|
291
|
+
run = runs_by_id[row.id] if row.source == "dashboard"
|
|
292
|
+
|
|
293
|
+
if run
|
|
294
|
+
run.summary.merge(
|
|
295
|
+
source: "dashboard",
|
|
296
|
+
# Priced here rather than on AgentRun so both sources use one
|
|
297
|
+
# estimator, and so a run and its trace never quote different costs.
|
|
298
|
+
cost: row.cost,
|
|
299
|
+
instructions_version: digest_versions[run.instructions_digest]
|
|
300
|
+
)
|
|
301
|
+
else
|
|
302
|
+
row.as_json.merge(id: row.to_param, record_id: row.id)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Same contract as Api::SandboxesController#run: 402 + usage stats so the
|
|
307
|
+
# frontend can show the upgrade prompt.
|
|
308
|
+
def set_agent
|
|
309
|
+
@agent = owner_agents.find(params[:id])
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def agent_params
|
|
313
|
+
params.require(:agent).permit(
|
|
314
|
+
:name, :description, :provider, :model, :instructions,
|
|
315
|
+
:preset_type, :agent_class_name, :status,
|
|
316
|
+
appearance: {},
|
|
317
|
+
action_prompts: [ :name, :prompt, :expose_as_tool ],
|
|
318
|
+
instruction_sets: [],
|
|
319
|
+
tools: [],
|
|
320
|
+
mcp_servers: [],
|
|
321
|
+
model_config: {},
|
|
322
|
+
response_format: {}
|
|
323
|
+
)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def agent_json(agent, include_details: false)
|
|
327
|
+
json = {
|
|
328
|
+
id: agent.id,
|
|
329
|
+
name: agent.name,
|
|
330
|
+
slug: agent.slug,
|
|
331
|
+
description: agent.description,
|
|
332
|
+
provider: agent.provider,
|
|
333
|
+
model: agent.model,
|
|
334
|
+
status: agent.status,
|
|
335
|
+
preset_type: agent.preset_type,
|
|
336
|
+
appearance: agent.appearance,
|
|
337
|
+
version_count: agent.version_count,
|
|
338
|
+
created_at: agent.created_at,
|
|
339
|
+
updated_at: agent.updated_at
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if include_details
|
|
343
|
+
json.merge!(
|
|
344
|
+
instructions: agent.instructions,
|
|
345
|
+
action_prompts: agent.action_prompts,
|
|
346
|
+
instruction_sets: agent.instruction_sets,
|
|
347
|
+
tools: agent.tools,
|
|
348
|
+
mcp_servers: agent.mcp_servers,
|
|
349
|
+
model_config: agent.model_config,
|
|
350
|
+
response_format: agent.response_format,
|
|
351
|
+
agent_class_name: agent.agent_class_name,
|
|
352
|
+
telemetry_agent_class: agent.telemetry_agent_class
|
|
353
|
+
)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
json
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def version_json(version, include_diff: false)
|
|
360
|
+
json = {
|
|
361
|
+
id: version.id,
|
|
362
|
+
version_number: version.version_number,
|
|
363
|
+
change_summary: version.change_summary,
|
|
364
|
+
created_by: version.created_by,
|
|
365
|
+
created_at: version.created_at,
|
|
366
|
+
is_latest: version.latest?
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if include_diff && version.previous
|
|
370
|
+
json[:diff] = version.diff(version.previous)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
json
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def default_appearance_for(preset)
|
|
377
|
+
appearances = {
|
|
378
|
+
"terminal" => { hat: "fedora", heldItem: "terminal", theme: "emerald" },
|
|
379
|
+
"webDeveloper" => { hat: "safari", heldItem: "browser", theme: "blue" },
|
|
380
|
+
"documentAnalysis" => { hat: "fedora", heldItem: "document", theme: "amber" },
|
|
381
|
+
"writing" => { hat: "fedora", hatAccessory: "feather", heldItem: "scroll", theme: "purple" },
|
|
382
|
+
"research" => { hat: "safari", heldItem: "magnifyingGlass", theme: "teal" },
|
|
383
|
+
"playwright" => { hat: "fedora", hatAccessory: "theaterMasks", heldItem: "browser", theme: "rose" }
|
|
384
|
+
}
|
|
385
|
+
appearances[preset] || { theme: "default" }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def suggested_tools_for(preset)
|
|
389
|
+
tools = {
|
|
390
|
+
"terminal" => %w[terminal filesystem code],
|
|
391
|
+
"webDeveloper" => %w[terminal filesystem code playwright],
|
|
392
|
+
"documentAnalysis" => %w[filesystem search],
|
|
393
|
+
"writing" => %w[edit translate],
|
|
394
|
+
"research" => %w[fetch search memory],
|
|
395
|
+
"playwright" => %w[playwright filesystem]
|
|
396
|
+
}
|
|
397
|
+
tools[preset] || []
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def suggested_instructions_for(preset)
|
|
401
|
+
instructions = {
|
|
402
|
+
"terminal" => %w[github docker kubernetes],
|
|
403
|
+
"webDeveloper" => %w[github ruby rails typescript],
|
|
404
|
+
"research" => %w[github python],
|
|
405
|
+
"playwright" => %w[github typescript]
|
|
406
|
+
}
|
|
407
|
+
instructions[preset] || []
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
class AnalyticsController < BaseController
|
|
6
|
+
# GET /api/analytics
|
|
7
|
+
def index
|
|
8
|
+
days = (params[:days] || 30).to_i
|
|
9
|
+
start_date = days.days.ago.beginning_of_day
|
|
10
|
+
|
|
11
|
+
# Table names are interpolated rather than written literally: the
|
|
12
|
+
# engine's tables carry a configurable prefix.
|
|
13
|
+
runs_table = AgentRun.table_name
|
|
14
|
+
agents_table = ActionAgent::Agent.table_name
|
|
15
|
+
|
|
16
|
+
agents = owner_agents
|
|
17
|
+
runs = AgentRun.joins(:agent).where(agent: agents).where("#{runs_table}.created_at >= ?", start_date)
|
|
18
|
+
|
|
19
|
+
# Overall stats
|
|
20
|
+
total_agents = agents.count
|
|
21
|
+
active_agents = agents.where(status: :active).count
|
|
22
|
+
total_runs = runs.count
|
|
23
|
+
completed_runs = runs.where(status: :complete).count
|
|
24
|
+
failed_runs = runs.where(status: :failed).count
|
|
25
|
+
|
|
26
|
+
# Token usage
|
|
27
|
+
total_tokens = runs.sum(:total_tokens)
|
|
28
|
+
total_input_tokens = runs.sum(:input_tokens)
|
|
29
|
+
total_output_tokens = runs.sum(:output_tokens)
|
|
30
|
+
|
|
31
|
+
# Average metrics
|
|
32
|
+
avg_duration = runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
|
|
33
|
+
avg_tokens_per_run = total_runs > 0 ? (total_tokens.to_f / total_runs).round : 0
|
|
34
|
+
|
|
35
|
+
# Runs over time
|
|
36
|
+
runs_by_day = runs.group("DATE(#{runs_table}.created_at)")
|
|
37
|
+
.select("DATE(#{runs_table}.created_at) as date, COUNT(*) as count")
|
|
38
|
+
.order("date")
|
|
39
|
+
.map { |r| { date: r.date.to_s, count: r.count } }
|
|
40
|
+
|
|
41
|
+
# Token usage over time
|
|
42
|
+
tokens_by_day = runs.group("DATE(#{runs_table}.created_at)")
|
|
43
|
+
.select("DATE(#{runs_table}.created_at) as date, SUM(total_tokens) as tokens")
|
|
44
|
+
.order("date")
|
|
45
|
+
.map { |r| { date: r.date.to_s, tokens: r.tokens || 0 } }
|
|
46
|
+
|
|
47
|
+
# Top agents by usage
|
|
48
|
+
top_agents = agents.joins(:agent_runs)
|
|
49
|
+
.where("#{runs_table}.created_at >= ?", start_date)
|
|
50
|
+
.group("#{agents_table}.id", "#{agents_table}.name", "#{agents_table}.slug")
|
|
51
|
+
.select(
|
|
52
|
+
"#{agents_table}.id, #{agents_table}.name, #{agents_table}.slug, " \
|
|
53
|
+
"COUNT(#{runs_table}.id) as run_count, SUM(#{runs_table}.total_tokens) as total_tokens"
|
|
54
|
+
)
|
|
55
|
+
.order("run_count DESC")
|
|
56
|
+
.limit(5)
|
|
57
|
+
.map { |a| { id: a.id, name: a.name, slug: a.slug, run_count: a.run_count, total_tokens: a.total_tokens || 0 } }
|
|
58
|
+
|
|
59
|
+
# Status distribution
|
|
60
|
+
status_breakdown = runs.group(:status).count.transform_keys(&:to_s)
|
|
61
|
+
|
|
62
|
+
# Provider distribution
|
|
63
|
+
provider_breakdown = runs.joins(:agent)
|
|
64
|
+
.group("#{agents_table}.provider")
|
|
65
|
+
.count
|
|
66
|
+
.transform_keys(&:to_s)
|
|
67
|
+
|
|
68
|
+
render json: {
|
|
69
|
+
period_days: days,
|
|
70
|
+
summary: {
|
|
71
|
+
total_agents: total_agents,
|
|
72
|
+
active_agents: active_agents,
|
|
73
|
+
total_runs: total_runs,
|
|
74
|
+
completed_runs: completed_runs,
|
|
75
|
+
failed_runs: failed_runs,
|
|
76
|
+
success_rate: total_runs > 0 ? ((completed_runs.to_f / total_runs) * 100).round(1) : 0,
|
|
77
|
+
avg_duration_ms: avg_duration,
|
|
78
|
+
total_tokens: total_tokens,
|
|
79
|
+
total_input_tokens: total_input_tokens,
|
|
80
|
+
total_output_tokens: total_output_tokens,
|
|
81
|
+
avg_tokens_per_run: avg_tokens_per_run
|
|
82
|
+
},
|
|
83
|
+
charts: {
|
|
84
|
+
runs_by_day: runs_by_day,
|
|
85
|
+
tokens_by_day: tokens_by_day
|
|
86
|
+
},
|
|
87
|
+
top_agents: top_agents,
|
|
88
|
+
status_breakdown: status_breakdown,
|
|
89
|
+
provider_breakdown: provider_breakdown
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# Platform API keys (Settings -> API Keys). The full token is returned
|
|
6
|
+
# exactly once — in the create response — and masked everywhere else.
|
|
7
|
+
class ApiKeysController < BaseController
|
|
8
|
+
before_action :require_owner!
|
|
9
|
+
|
|
10
|
+
# GET /api/api_keys
|
|
11
|
+
def index
|
|
12
|
+
render json: {
|
|
13
|
+
api_keys: owned(ApiKey).order(created_at: :desc).map { |key| serialize(key) }
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# POST /api/api_keys
|
|
18
|
+
def create
|
|
19
|
+
api_key = owned(ApiKey).create!(name: params.require(:name))
|
|
20
|
+
|
|
21
|
+
render json: {
|
|
22
|
+
api_key: serialize(api_key).merge(token: api_key.token)
|
|
23
|
+
}, status: :created
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# DELETE /api/api_keys/:id
|
|
27
|
+
def destroy
|
|
28
|
+
owned(ApiKey).find(params[:id]).destroy!
|
|
29
|
+
head :no_content
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def serialize(api_key)
|
|
35
|
+
{
|
|
36
|
+
id: api_key.id,
|
|
37
|
+
name: api_key.name,
|
|
38
|
+
masked_token: api_key.masked_token,
|
|
39
|
+
created_at: api_key.created_at.iso8601,
|
|
40
|
+
last_used_at: api_key.last_used_at&.iso8601
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|