actionagent 1.2.2 → 1.5.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/README.md +14 -3
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +69 -43
- data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
- data/app/controllers/action_agent/api/agents_controller.rb +191 -56
- data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
- data/app/controllers/action_agent/api/base_controller.rb +16 -0
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
- data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
- data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
- data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
- data/app/controllers/action_agent/api/templates_controller.rb +25 -21
- data/app/controllers/action_agent/api/traces_controller.rb +25 -5
- data/app/controllers/action_agent/api/usage_controller.rb +20 -0
- data/app/controllers/action_agent/application_controller.rb +25 -2
- data/app/controllers/action_agent/dashboard_controller.rb +3 -1
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
- data/app/jobs/action_agent/agent_execution_job.rb +40 -20
- data/app/jobs/action_agent/application_job.rb +7 -3
- data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
- data/app/models/action_agent/agent.rb +74 -23
- data/app/models/action_agent/agent_run.rb +99 -0
- data/app/models/action_agent/agent_template.rb +22 -7
- data/app/models/action_agent/evaluation.rb +64 -4
- data/app/models/action_agent/evaluation_run.rb +190 -2
- data/app/models/action_agent/evaluation_scenario.rb +59 -0
- data/app/models/action_agent/evaluation_scenario_result.rb +86 -0
- data/app/models/action_agent/recording_action.rb +11 -7
- data/app/models/action_agent/sandbox_session.rb +1 -1
- data/app/models/action_agent/session_recording.rb +31 -8
- data/app/models/action_agent/telemetry_trace.rb +126 -3
- data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
- data/app/models/concerns/action_agent/ownable.rb +15 -2
- data/app/queries/action_agent/metrics_report.rb +498 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
- data/app/services/action_agent/agent_execution_service.rb +294 -16
- data/app/services/action_agent/agent_registrar.rb +7 -6
- data/app/services/action_agent/agent_toolbox.rb +49 -7
- data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
- data/app/services/action_agent/evaluation_evidence.rb +234 -0
- data/app/services/action_agent/evaluation_runner_service.rb +13 -3
- data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
- data/app/services/action_agent/mcp_catalog.rb +46 -8
- data/app/services/action_agent/mcp_client.rb +167 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
- data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
- data/app/services/action_agent/playwright_mcp_client.rb +11 -126
- data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
- data/app/services/action_agent/tool_discovery.rb +22 -8
- data/config/routes.rb +36 -3
- data/lib/action_agent/assistant_request_filter.rb +22 -0
- data/lib/action_agent/engine.rb +106 -19
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +104 -6
- data/lib/generators/action_agent/install_generator.rb +20 -7
- data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
- data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
- data/lib/tasks/action_agent.rake +9 -0
- metadata +22 -5
|
@@ -28,7 +28,7 @@ module ActionAgent
|
|
|
28
28
|
def index
|
|
29
29
|
# Scoped to the caller's own agents: this listed (and counted) every
|
|
30
30
|
# run in the database regardless of who owned it.
|
|
31
|
-
scope = AgentRun.includes(:agent).where(agent: owner_agents).recent
|
|
31
|
+
scope = AgentRun.includes(:agent).with_attachments.where(agent: owner_agents).recent
|
|
32
32
|
|
|
33
33
|
scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
|
|
34
34
|
scope = scope.where(status: params[:status]) if params[:status].present?
|
|
@@ -63,13 +63,7 @@ module ActionAgent
|
|
|
63
63
|
def interaction_messages(run)
|
|
64
64
|
return [] if run.trace_id.blank?
|
|
65
65
|
|
|
66
|
-
|
|
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
|
|
66
|
+
messages, start_index = run_slice_start(run)
|
|
73
67
|
return [] unless start_index
|
|
74
68
|
|
|
75
69
|
slice = [ messages[start_index] ]
|
|
@@ -93,6 +87,30 @@ module ActionAgent
|
|
|
93
87
|
serialized
|
|
94
88
|
end
|
|
95
89
|
|
|
90
|
+
# solid_agent keys the persisted context by action_name, so each action
|
|
91
|
+
# has its own stream. The run's own action is searched first; a run
|
|
92
|
+
# whose action is blank or legacy (or whose trace landed in another
|
|
93
|
+
# stream) falls back to every context of the agent, newest first.
|
|
94
|
+
# Picking only the newest context regardless of action left the
|
|
95
|
+
# conversation empty for every run of any other action.
|
|
96
|
+
def run_slice_start(run)
|
|
97
|
+
contexts = AgentContext.for_agents(Agent.where(id: run.agent_id)).order(created_at: :desc)
|
|
98
|
+
action = run.action_name.presence || run.output_metadata&.dig("action").presence || Agent::DEFAULT_ACTION
|
|
99
|
+
|
|
100
|
+
candidates = contexts.for_action(action).to_a
|
|
101
|
+
candidates += contexts.to_a.reject { |context| candidates.include?(context) }
|
|
102
|
+
|
|
103
|
+
candidates.each do |context|
|
|
104
|
+
messages = context.messages.chronological.to_a
|
|
105
|
+
start_index = messages.index do |message|
|
|
106
|
+
message.role == "user" && message.provenance&.dig("trace_id") == run.trace_id
|
|
107
|
+
end
|
|
108
|
+
return [ messages, start_index ] if start_index
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
[ [], nil ]
|
|
112
|
+
end
|
|
113
|
+
|
|
96
114
|
def run_json(run, include_agent: false)
|
|
97
115
|
json = {
|
|
98
116
|
id: run.id,
|
|
@@ -107,6 +125,8 @@ module ActionAgent
|
|
|
107
125
|
total_tokens: run.total_tokens,
|
|
108
126
|
error_message: run.error_message,
|
|
109
127
|
trace_id: run.trace_id,
|
|
128
|
+
attachments: run.attachment_manifest,
|
|
129
|
+
context_id: run.context_id,
|
|
110
130
|
logs: run.logs,
|
|
111
131
|
started_at: run.started_at,
|
|
112
132
|
completed_at: run.completed_at,
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module ActionAgent
|
|
4
4
|
module Api
|
|
5
5
|
class AgentsController < BaseController
|
|
6
|
+
include AgentSerialization
|
|
7
|
+
|
|
6
8
|
# Ranking for the agent cards. Every dimension except "recent" reads the
|
|
7
9
|
# scorecard, which is computed in Ruby over both execution sources, so
|
|
8
10
|
# the ordering is applied there rather than in the SQL scope.
|
|
@@ -14,11 +16,23 @@ module ActionAgent
|
|
|
14
16
|
"tokens" => "Most tokens"
|
|
15
17
|
}.freeze
|
|
16
18
|
DEFAULT_LIST_SORT = "recent"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
# Conversations returned to the runner's picker when no limit is asked for.
|
|
20
|
+
CONVERSATIONS_LIMIT = 50
|
|
21
|
+
# Keywords Agent#execute takes in its own right, which per-run overrides
|
|
22
|
+
# must never supply (see #execution_params).
|
|
23
|
+
RESERVED_EXECUTION_KEYS = [ :attachments, :action ].freeze
|
|
24
|
+
|
|
25
|
+
before_action :set_agent, only: [
|
|
26
|
+
:show, :update, :destroy, :versions, :runs, :execute, :test, :restore, :duplicate, :export, :analytics,
|
|
27
|
+
:conversations, :create_conversation
|
|
28
|
+
]
|
|
19
29
|
before_action :require_execution_enabled!, only: [ :execute, :test ]
|
|
20
30
|
before_action :require_owner!, only: [ :execute, :test ]
|
|
31
|
+
before_action :require_executable_agent!, only: [ :execute, :test, :update, :restore, :create_conversation ]
|
|
21
32
|
before_action :enforce_execution_quota!, only: [ :execute, :test ]
|
|
33
|
+
before_action :require_prompt!, only: [ :execute, :test ]
|
|
34
|
+
|
|
35
|
+
rescue_from AgentRun::AttachmentsUnavailable, with: :attachments_unavailable
|
|
22
36
|
|
|
23
37
|
# GET /api/agents
|
|
24
38
|
def index
|
|
@@ -62,7 +76,7 @@ module ActionAgent
|
|
|
62
76
|
render json: {
|
|
63
77
|
agent: agent_json(@agent, include_details: true),
|
|
64
78
|
versions: @agent.agent_versions.recent.limit(10).map { |v| version_json(v) },
|
|
65
|
-
recent_runs: @agent.agent_runs.recent.limit(5).map(&:summary)
|
|
79
|
+
recent_runs: @agent.agent_runs.with_attachments.recent.limit(5).map(&:summary)
|
|
66
80
|
}
|
|
67
81
|
end
|
|
68
82
|
|
|
@@ -131,7 +145,7 @@ module ActionAgent
|
|
|
131
145
|
# One digest->version map for the page; labels each run's instructions
|
|
132
146
|
# with the agent version that introduced them where one matches.
|
|
133
147
|
digest_versions = @agent.instructions_digest_versions
|
|
134
|
-
runs_by_id = AgentRun.where(id: executions[:rows].select { |r| r.source == "dashboard" }.map(&:id))
|
|
148
|
+
runs_by_id = AgentRun.with_attachments.where(id: executions[:rows].select { |r| r.source == "dashboard" }.map(&:id))
|
|
135
149
|
.index_by(&:id)
|
|
136
150
|
|
|
137
151
|
render json: {
|
|
@@ -147,11 +161,16 @@ module ActionAgent
|
|
|
147
161
|
end
|
|
148
162
|
|
|
149
163
|
# POST /api/agents/:id/execute
|
|
164
|
+
#
|
|
165
|
+
# JSON as before, or multipart from the runner's composer: the new
|
|
166
|
+
# user message, its files (attachments[]) and the conversation to
|
|
167
|
+
# continue (params[context_id], or a top-level context_id).
|
|
150
168
|
def execute
|
|
151
169
|
run = @agent.execute(
|
|
152
|
-
|
|
170
|
+
execution_prompt,
|
|
153
171
|
action: params[:action_name],
|
|
154
|
-
|
|
172
|
+
attachments: uploaded_attachments,
|
|
173
|
+
**execution_params
|
|
155
174
|
)
|
|
156
175
|
record_execution_usage
|
|
157
176
|
|
|
@@ -161,15 +180,54 @@ module ActionAgent
|
|
|
161
180
|
# POST /api/agents/:id/test
|
|
162
181
|
def test
|
|
163
182
|
run = @agent.test_execute(
|
|
164
|
-
|
|
183
|
+
execution_prompt,
|
|
165
184
|
action: params[:action_name],
|
|
166
|
-
|
|
185
|
+
attachments: uploaded_attachments,
|
|
186
|
+
**execution_params
|
|
167
187
|
)
|
|
168
188
|
record_execution_usage
|
|
169
189
|
|
|
170
190
|
render json: { run: run.summary, output: run.output }
|
|
171
191
|
end
|
|
172
192
|
|
|
193
|
+
# GET /api/agents/:id/conversations
|
|
194
|
+
#
|
|
195
|
+
# The agent's persisted contexts, newest first — the runner's
|
|
196
|
+
# conversation picker, narrowed to one action when asked.
|
|
197
|
+
def conversations
|
|
198
|
+
limit = params.fetch(:limit, CONVERSATIONS_LIMIT).to_i.clamp(1, 200)
|
|
199
|
+
contexts = agent_contexts.order(created_at: :desc)
|
|
200
|
+
contexts = contexts.for_action(params[:action_name]) if params[:action_name].present?
|
|
201
|
+
# The picker is refetched on every seeded or deleted message, and the
|
|
202
|
+
# list grows a row per New conversation, so it is bounded like every
|
|
203
|
+
# other collection this API serves.
|
|
204
|
+
contexts = contexts.limit(limit).to_a
|
|
205
|
+
counts = AgentMessage.where(agent_context_id: contexts.map(&:id)).group(:agent_context_id).count
|
|
206
|
+
|
|
207
|
+
render json: {
|
|
208
|
+
conversations: contexts.map { |context| conversation_json(context, counts[context.id] || 0) }
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# POST /api/agents/:id/conversations
|
|
213
|
+
#
|
|
214
|
+
# Starts an empty context for an action, so the runner's very first
|
|
215
|
+
# message already lands in a pinned conversation rather than in the
|
|
216
|
+
# agent's default stream.
|
|
217
|
+
def create_conversation
|
|
218
|
+
action = params[:action_name].presence || Agent::DEFAULT_ACTION
|
|
219
|
+
action = Agent::DEFAULT_ACTION unless @agent.available_actions.include?(action)
|
|
220
|
+
|
|
221
|
+
context = AgentContext.create!(
|
|
222
|
+
contextable: @agent,
|
|
223
|
+
agent_name: @agent.telemetry_agent_class,
|
|
224
|
+
action_name: action,
|
|
225
|
+
instructions: @agent.composed_instructions_for(action)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
render json: { conversation: conversation_json(context, 0) }, status: :created
|
|
229
|
+
end
|
|
230
|
+
|
|
173
231
|
# POST /api/agents/:id/duplicate
|
|
174
232
|
def duplicate
|
|
175
233
|
new_agent = @agent.dup
|
|
@@ -199,37 +257,82 @@ module ActionAgent
|
|
|
199
257
|
end
|
|
200
258
|
|
|
201
259
|
# GET /api/agents/:id/analytics
|
|
260
|
+
#
|
|
261
|
+
# Every execution of this agent, whoever ran it — the same merged model
|
|
262
|
+
# the runs list and the scorecard use. Agents observed from telemetry
|
|
263
|
+
# have no AgentRun rows at all, so a runs-only aggregate showed them
|
|
264
|
+
# with all-zero metrics beside a card and a runs list reporting real
|
|
265
|
+
# traffic.
|
|
202
266
|
def analytics
|
|
203
267
|
days = (params[:days] || 30).to_i
|
|
204
268
|
start_date = days.days.ago.beginning_of_day
|
|
205
269
|
|
|
206
270
|
runs = @agent.agent_runs.where("created_at >= ?", start_date)
|
|
271
|
+
traces = AgentExecutions.unclaimed_traces([ @agent.id ], since: start_date, owner: current_owner)
|
|
272
|
+
traces_table = ActionAgent.trace_model.table_name
|
|
273
|
+
trace_tokens_sql = Arel.sql(
|
|
274
|
+
"COALESCE(#{traces_table}.total_input_tokens, 0) + COALESCE(#{traces_table}.total_output_tokens, 0) + " \
|
|
275
|
+
"COALESCE(#{traces_table}.total_thinking_tokens, 0)"
|
|
276
|
+
)
|
|
207
277
|
|
|
208
|
-
#
|
|
209
|
-
|
|
278
|
+
# Dashboard runs
|
|
279
|
+
run_count = runs.count
|
|
210
280
|
completed_runs = runs.where(status: :complete).count
|
|
211
281
|
failed_runs = runs.where(status: :failed).count
|
|
212
|
-
|
|
213
|
-
|
|
282
|
+
timed_runs = runs.where.not(duration_ms: nil)
|
|
283
|
+
run_tokens = runs.sum(:total_tokens)
|
|
284
|
+
|
|
285
|
+
# Reported executions
|
|
286
|
+
trace_count = traces.count
|
|
287
|
+
trace_failures = traces.where(status: "ERROR").count
|
|
288
|
+
timed_traces = traces.where.not(total_duration_ms: nil)
|
|
289
|
+
trace_tokens = traces.sum(trace_tokens_sql)
|
|
290
|
+
|
|
291
|
+
total_runs = run_count + trace_count
|
|
292
|
+
completed_runs += trace_count - trace_failures
|
|
293
|
+
failed_runs += trace_failures
|
|
294
|
+
total_tokens = run_tokens + trace_tokens
|
|
214
295
|
avg_tokens = total_runs > 0 ? (total_tokens.to_f / total_runs).round : 0
|
|
215
296
|
|
|
216
|
-
#
|
|
217
|
-
|
|
297
|
+
# Weighted across both sources, so one side's long tail counts for
|
|
298
|
+
# what it is.
|
|
299
|
+
timed_total = timed_runs.count + timed_traces.count
|
|
300
|
+
avg_duration = if timed_total.positive?
|
|
301
|
+
(timed_runs.sum(:duration_ms) + timed_traces.sum(:total_duration_ms)).to_f / timed_total
|
|
302
|
+
else
|
|
303
|
+
0
|
|
304
|
+
end.round
|
|
305
|
+
|
|
306
|
+
# Runs by day, zero-filled across the window (see AnalyticsController)
|
|
307
|
+
by_day = Hash.new { |hash, date| hash[date] = { date: date, count: 0, tokens: 0 } }
|
|
308
|
+
runs.group("DATE(created_at)")
|
|
218
309
|
.select("DATE(created_at) as date, COUNT(*) as count, SUM(total_tokens) as tokens")
|
|
219
|
-
.
|
|
220
|
-
|
|
310
|
+
.each { |r| by_day[r.date.to_s].merge!(count: r.count, tokens: r.tokens || 0) }
|
|
311
|
+
trace_day_sql = Arel.sql("DATE(#{traces_table}.timestamp)")
|
|
312
|
+
trace_counts = traces.group(trace_day_sql).count
|
|
313
|
+
trace_token_sums = traces.group(trace_day_sql).sum(trace_tokens_sql)
|
|
314
|
+
trace_counts.each do |date, count|
|
|
315
|
+
bucket = by_day[date.to_s]
|
|
316
|
+
bucket[:count] += count
|
|
317
|
+
bucket[:tokens] += trace_token_sums[date].to_i
|
|
318
|
+
end
|
|
319
|
+
runs_by_day = (start_date.to_date..Date.current).map { |day| by_day[day.to_s] }
|
|
221
320
|
|
|
222
|
-
# Status breakdown
|
|
321
|
+
# Status breakdown. A reported execution is complete unless its trace
|
|
322
|
+
# errored; those are the only two states a trace can be in.
|
|
223
323
|
status_breakdown = runs.group(:status).count.transform_keys(&:to_s)
|
|
324
|
+
status_breakdown["complete"] = status_breakdown.fetch("complete", 0) + (trace_count - trace_failures)
|
|
325
|
+
status_breakdown["failed"] = status_breakdown.fetch("failed", 0) + trace_failures
|
|
326
|
+
status_breakdown.delete_if { |_status, count| count.zero? }
|
|
224
327
|
|
|
225
|
-
# Recent errors
|
|
328
|
+
# Recent errors, from both sources
|
|
226
329
|
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
|
-
}
|
|
330
|
+
{ id: run.id, source: "dashboard", error: run.error_message&.truncate(200), created_at: run.created_at }
|
|
232
331
|
end
|
|
332
|
+
recent_errors += traces.where(status: "ERROR").order(timestamp: :desc).limit(5).map do |trace|
|
|
333
|
+
{ id: "trace-#{trace.id}", source: "reported", error: trace.error_message&.truncate(200), created_at: trace.timestamp }
|
|
334
|
+
end
|
|
335
|
+
recent_errors = recent_errors.sort_by { |row| row[:created_at] }.reverse.first(5)
|
|
233
336
|
|
|
234
337
|
render json: {
|
|
235
338
|
period_days: days,
|
|
@@ -265,6 +368,71 @@ module ActionAgent
|
|
|
265
368
|
|
|
266
369
|
private
|
|
267
370
|
|
|
371
|
+
# Observed agents were discovered from reported telemetry; the platform
|
|
372
|
+
# has no configuration to run them with (a placeholder model, no
|
|
373
|
+
# instructions), so executing one only manufactured a failed run that
|
|
374
|
+
# was then blended into the clean scorecard its telemetry had built.
|
|
375
|
+
# Duplicating an observed agent yields a draft that can be run.
|
|
376
|
+
def require_executable_agent!
|
|
377
|
+
return unless @agent.observed?
|
|
378
|
+
|
|
379
|
+
render json: {
|
|
380
|
+
error: "Observed agents are read-only — duplicate this agent to create an executable copy"
|
|
381
|
+
}, status: :unprocessable_entity
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# A message may be empty only when files carry the request.
|
|
385
|
+
def require_prompt!
|
|
386
|
+
return if params[:prompt].present? || uploaded_attachments.any?
|
|
387
|
+
|
|
388
|
+
render json: { error: "Prompt can't be blank unless files are attached" }, status: :unprocessable_entity
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def execution_prompt
|
|
392
|
+
params[:prompt].presence || "(see attached files)"
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Multipart files only: a JSON body can't carry one, and an empty file
|
|
396
|
+
# input arrives as a blank string.
|
|
397
|
+
def uploaded_attachments
|
|
398
|
+
@uploaded_attachments ||= Array(params[:attachments]).select { |file| file.respond_to?(:original_filename) }
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Per-run overrides (provider/model overrides, the pinned context)
|
|
402
|
+
# for input_params. context_id is stored as an integer so the JSON
|
|
403
|
+
# column reads the same whether a form or a JSON body delivered it;
|
|
404
|
+
# anything that isn't one is dropped rather than pinned to nothing.
|
|
405
|
+
def execution_params
|
|
406
|
+
extra = params.fetch(:params, {}).to_unsafe_h.symbolize_keys
|
|
407
|
+
# These reach Agent#execute as keywords, and a keyword splat wins over
|
|
408
|
+
# the arguments before it: left in, params[params][attachments] would
|
|
409
|
+
# replace the uploaded files with anything the caller names, and
|
|
410
|
+
# params[params][action] the action. They are the controller's to set.
|
|
411
|
+
extra.except!(*RESERVED_EXECUTION_KEYS)
|
|
412
|
+
context_id = Integer(extra.delete(:context_id).presence || params[:context_id].presence || 0, exception: false)
|
|
413
|
+
extra[:context_id] = context_id if context_id&.positive?
|
|
414
|
+
extra
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def attachments_unavailable(exception)
|
|
418
|
+
render json: { error: exception.message }, status: :unprocessable_entity
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def agent_contexts
|
|
422
|
+
AgentContext.where(contextable: @agent)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def conversation_json(context, message_count)
|
|
426
|
+
{
|
|
427
|
+
id: context.id,
|
|
428
|
+
action_name: context.action_name,
|
|
429
|
+
agent_name: context.agent_name,
|
|
430
|
+
message_count: message_count,
|
|
431
|
+
last_activity_at: context.updated_at.iso8601,
|
|
432
|
+
created_at: context.created_at.iso8601
|
|
433
|
+
}
|
|
434
|
+
end
|
|
435
|
+
|
|
268
436
|
def list_sort(requested)
|
|
269
437
|
LIST_SORTS.key?(requested.to_s) ? requested.to_s : DEFAULT_LIST_SORT
|
|
270
438
|
end
|
|
@@ -323,39 +491,6 @@ module ActionAgent
|
|
|
323
491
|
)
|
|
324
492
|
end
|
|
325
493
|
|
|
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
494
|
def version_json(version, include_diff: false)
|
|
360
495
|
json = {
|
|
361
496
|
id: version.id,
|
|
@@ -32,17 +32,26 @@ module ActionAgent
|
|
|
32
32
|
avg_duration = runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
|
|
33
33
|
avg_tokens_per_run = total_runs > 0 ? (total_tokens.to_f / total_runs).round : 0
|
|
34
34
|
|
|
35
|
-
# Runs over time
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
# Runs over time. Zero-filled across the window: grouping only
|
|
36
|
+
# returns days that have rows, and the chart draws whatever it gets
|
|
37
|
+
# as adjacent bars, so a 30-day window with runs on two far-apart
|
|
38
|
+
# days rendered as two neighbouring bars.
|
|
39
|
+
runs_by_day = zero_filled_days(
|
|
40
|
+
start_date,
|
|
41
|
+
runs.group("DATE(#{runs_table}.created_at)")
|
|
42
|
+
.select("DATE(#{runs_table}.created_at) as date, COUNT(*) as count")
|
|
43
|
+
.map { |r| { date: r.date.to_s, count: r.count } },
|
|
44
|
+
count: 0
|
|
45
|
+
)
|
|
40
46
|
|
|
41
47
|
# Token usage over time
|
|
42
|
-
tokens_by_day =
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
|
|
48
|
+
tokens_by_day = zero_filled_days(
|
|
49
|
+
start_date,
|
|
50
|
+
runs.group("DATE(#{runs_table}.created_at)")
|
|
51
|
+
.select("DATE(#{runs_table}.created_at) as date, SUM(total_tokens) as tokens")
|
|
52
|
+
.map { |r| { date: r.date.to_s, tokens: r.tokens || 0 } },
|
|
53
|
+
tokens: 0
|
|
54
|
+
)
|
|
46
55
|
|
|
47
56
|
# Top agents by usage
|
|
48
57
|
top_agents = agents.joins(:agent_runs)
|
|
@@ -89,6 +98,19 @@ module ActionAgent
|
|
|
89
98
|
provider_breakdown: provider_breakdown
|
|
90
99
|
}
|
|
91
100
|
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# One entry per calendar day from +start_date+ through today, in
|
|
105
|
+
# order, with +defaults+ for the days the grouped +rows+ did not
|
|
106
|
+
# mention. Keyed on the ISO date string so it reads the same whether
|
|
107
|
+
# the adapter returns DATE() as a Date or a String.
|
|
108
|
+
def zero_filled_days(start_date, rows, **defaults)
|
|
109
|
+
by_date = rows.index_by { |row| row[:date] }
|
|
110
|
+
(start_date.to_date..Date.current).map do |day|
|
|
111
|
+
by_date[day.to_s] || defaults.merge(date: day.to_s)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
92
114
|
end
|
|
93
115
|
end
|
|
94
116
|
end
|
|
@@ -20,9 +20,25 @@ module ActionAgent
|
|
|
20
20
|
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
21
21
|
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
|
|
22
22
|
rescue_from ActionController::ParameterMissing, with: :bad_request
|
|
23
|
+
rescue_from ActiveRecord::Encryption::Errors::Configuration, with: :encryption_unconfigured
|
|
23
24
|
|
|
24
25
|
private
|
|
25
26
|
|
|
27
|
+
# API keys and provider credentials are encrypted at rest, which needs
|
|
28
|
+
# Active Record Encryption keys. The engine derives fallback keys when
|
|
29
|
+
# the host set none (see Engine's action_agent.active_record_encryption
|
|
30
|
+
# initializer), so this is only reached when that fallback was disabled
|
|
31
|
+
# or the host's own keys are broken — in which case the operator gets
|
|
32
|
+
# told what to do rather than an HTML 500 the Settings view collapses
|
|
33
|
+
# into "Could not create the API key."
|
|
34
|
+
def encryption_unconfigured(exception)
|
|
35
|
+
render json: {
|
|
36
|
+
error: "Active Record encryption is not configured (#{exception.message}). " \
|
|
37
|
+
"Run `rails db:encryption:init` and add the keys to your credentials, " \
|
|
38
|
+
"or set ActionAgent.encrypt_credentials = false to store credentials unencrypted."
|
|
39
|
+
}, status: :service_unavailable
|
|
40
|
+
end
|
|
41
|
+
|
|
26
42
|
# Scopes +relation+ to the caller, following the model's own
|
|
27
43
|
# declaration. Unowned models (a single-user install, or a model that
|
|
28
44
|
# nothing owns) come back unfiltered.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
class DashboardAssistantController < BaseController
|
|
6
|
+
protect_from_forgery with: :exception
|
|
7
|
+
|
|
8
|
+
before_action :require_assistant_enabled!
|
|
9
|
+
before_action :require_owner!
|
|
10
|
+
before_action :require_execution_enabled!, only: :create
|
|
11
|
+
before_action :enforce_execution_quota!, only: :create
|
|
12
|
+
|
|
13
|
+
rescue_from DashboardAssistantService::InvalidInput, with: :invalid_input
|
|
14
|
+
rescue_from DashboardAssistantService::ProcessingConsentRequired, with: :processing_consent_required
|
|
15
|
+
rescue_from DashboardAssistantService::SetupRequired, with: :setup_required
|
|
16
|
+
# Rails 8.2 verifies forgery protection from the browser's Sec-Fetch-Site
|
|
17
|
+
# header, renamed the failure to InvalidCrossOriginRequest, and deprecated
|
|
18
|
+
# the old name. Rescue whichever names the running Rails defines, so a
|
|
19
|
+
# rejected request answers with the dashboard's JSON either way.
|
|
20
|
+
# const_defined? does not fire the deprecation the bare constant would.
|
|
21
|
+
rescue_from ActionController::InvalidCrossOriginRequest, with: :invalid_authenticity_token
|
|
22
|
+
if ActionController.const_defined?(:InvalidAuthenticityToken, false)
|
|
23
|
+
rescue_from ActionController::InvalidAuthenticityToken, with: :invalid_authenticity_token
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def show
|
|
27
|
+
render json: DashboardAssistantService.new(owner: current_owner).configuration
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create
|
|
31
|
+
input = params.to_unsafe_h.symbolize_keys.slice(:message, :history, :provider, :model, :allow_provider_processing)
|
|
32
|
+
assistant = DashboardAssistantService.new(owner: current_owner, **input)
|
|
33
|
+
assistant.validate!
|
|
34
|
+
record_execution_usage
|
|
35
|
+
render json: assistant.call
|
|
36
|
+
rescue StandardError => exception
|
|
37
|
+
raise if exception.is_a?(DashboardAssistantService::InvalidInput) ||
|
|
38
|
+
exception.is_a?(DashboardAssistantService::ProcessingConsentRequired) ||
|
|
39
|
+
exception.is_a?(DashboardAssistantService::SetupRequired) ||
|
|
40
|
+
exception.is_a?(ActiveRecord::Encryption::Errors::Configuration)
|
|
41
|
+
|
|
42
|
+
# Provider exceptions can contain credentials or raw request bodies.
|
|
43
|
+
Rails.logger.warn("[DashboardAssistant] Generation failed (#{exception.class.name})")
|
|
44
|
+
render json: { error: "The assistant could not complete this request. Retry, or ask the dashboard administrator to check the server logs.", code: "generation_failed" }, status: :bad_gateway
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# The assistant ships as a development and CI tool (see
|
|
50
|
+
# ActionAgent.assistant_enabled). Where it is off, it is not a view a
|
|
51
|
+
# caller can reach by knowing the route: the dashboard omits it, and
|
|
52
|
+
# both endpoints refuse.
|
|
53
|
+
def require_assistant_enabled!
|
|
54
|
+
return if ActionAgent.assistant_enabled?
|
|
55
|
+
|
|
56
|
+
render json: {
|
|
57
|
+
error: "The dashboard assistant is available in development and test. " \
|
|
58
|
+
"Set ActionAgent.assistant_enabled = true to enable it in this environment.",
|
|
59
|
+
code: "assistant_disabled"
|
|
60
|
+
}, status: :forbidden
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def invalid_input(exception)
|
|
64
|
+
render json: { error: exception.message, code: "invalid_input" }, status: :unprocessable_entity
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def processing_consent_required(exception)
|
|
68
|
+
render json: { error: exception.message, code: "processing_consent_required" }, status: :unprocessable_entity
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def setup_required(exception)
|
|
72
|
+
render json: {
|
|
73
|
+
error: exception.message, code: "setup_required", setup_required: true,
|
|
74
|
+
action: { type: "open_settings", path: "/settings", label: "Configure provider" }
|
|
75
|
+
}, status: :service_unavailable
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def invalid_authenticity_token
|
|
79
|
+
render json: { error: "Refresh the dashboard before sending another message", code: "invalid_csrf_token" }, status: :unprocessable_entity
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|