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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -3
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +69 -43
  5. data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
  6. data/app/controllers/action_agent/api/agents_controller.rb +191 -56
  7. data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
  8. data/app/controllers/action_agent/api/base_controller.rb +16 -0
  9. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
  10. data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
  11. data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
  12. data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
  13. data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
  14. data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
  15. data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
  16. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
  17. data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
  18. data/app/controllers/action_agent/api/templates_controller.rb +25 -21
  19. data/app/controllers/action_agent/api/traces_controller.rb +25 -5
  20. data/app/controllers/action_agent/api/usage_controller.rb +20 -0
  21. data/app/controllers/action_agent/application_controller.rb +25 -2
  22. data/app/controllers/action_agent/dashboard_controller.rb +3 -1
  23. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
  24. data/app/jobs/action_agent/agent_execution_job.rb +40 -20
  25. data/app/jobs/action_agent/application_job.rb +7 -3
  26. data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
  27. data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
  28. data/app/models/action_agent/agent.rb +74 -23
  29. data/app/models/action_agent/agent_run.rb +99 -0
  30. data/app/models/action_agent/agent_template.rb +22 -7
  31. data/app/models/action_agent/evaluation.rb +64 -4
  32. data/app/models/action_agent/evaluation_run.rb +190 -2
  33. data/app/models/action_agent/evaluation_scenario.rb +59 -0
  34. data/app/models/action_agent/evaluation_scenario_result.rb +86 -0
  35. data/app/models/action_agent/recording_action.rb +11 -7
  36. data/app/models/action_agent/sandbox_session.rb +1 -1
  37. data/app/models/action_agent/session_recording.rb +31 -8
  38. data/app/models/action_agent/telemetry_trace.rb +126 -3
  39. data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
  40. data/app/models/concerns/action_agent/ownable.rb +15 -2
  41. data/app/queries/action_agent/metrics_report.rb +498 -0
  42. data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
  43. data/app/services/action_agent/agent_execution_service.rb +294 -16
  44. data/app/services/action_agent/agent_registrar.rb +7 -6
  45. data/app/services/action_agent/agent_toolbox.rb +49 -7
  46. data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
  47. data/app/services/action_agent/evaluation_evidence.rb +234 -0
  48. data/app/services/action_agent/evaluation_runner_service.rb +13 -3
  49. data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
  50. data/app/services/action_agent/mcp_catalog.rb +46 -8
  51. data/app/services/action_agent/mcp_client.rb +167 -0
  52. data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
  53. data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
  54. data/app/services/action_agent/playwright_mcp_client.rb +11 -126
  55. data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
  56. data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
  57. data/app/services/action_agent/tool_discovery.rb +22 -8
  58. data/config/routes.rb +36 -3
  59. data/lib/action_agent/assistant_request_filter.rb +22 -0
  60. data/lib/action_agent/engine.rb +106 -19
  61. data/lib/action_agent/version.rb +1 -1
  62. data/lib/action_agent.rb +104 -6
  63. data/lib/generators/action_agent/install_generator.rb +20 -7
  64. data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
  66. data/lib/tasks/action_agent.rake +9 -0
  67. 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
- 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
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
- before_action :set_agent, only: [ :show, :update, :destroy, :versions, :runs, :execute, :test, :restore, :duplicate, :export, :analytics ]
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
- params[:prompt],
170
+ execution_prompt,
153
171
  action: params[:action_name],
154
- **params.fetch(:params, {}).to_unsafe_h.symbolize_keys
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
- params[:prompt],
183
+ execution_prompt,
165
184
  action: params[:action_name],
166
- **params.fetch(:params, {}).to_unsafe_h.symbolize_keys
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
- # Calculate stats
209
- total_runs = runs.count
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
- avg_duration = runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
213
- total_tokens = runs.sum(:total_tokens)
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
- # Runs by day
217
- runs_by_day = runs.group("DATE(created_at)")
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
- .order("date")
220
- .map { |r| { date: r.date.to_s, count: r.count, tokens: r.tokens || 0 } }
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
- 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 } }
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 = 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 } }
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