actionagent 0.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +103 -0
  4. data/app/assets/builds/action_agent.css +2 -0
  5. data/app/assets/builds/action_agent.js +163 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
  7. data/app/controllers/action_agent/api/agents_controller.rb +411 -0
  8. data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
  9. data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
  10. data/app/controllers/action_agent/api/base_controller.rb +112 -0
  11. data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
  12. data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
  13. data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
  14. data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
  15. data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
  16. data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
  17. data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
  18. data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
  19. data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
  20. data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
  21. data/app/controllers/action_agent/api/templates_controller.rb +94 -0
  22. data/app/controllers/action_agent/api/tools_controller.rb +58 -0
  23. data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
  24. data/app/controllers/action_agent/api/traces_controller.rb +136 -0
  25. data/app/controllers/action_agent/application_controller.rb +105 -0
  26. data/app/controllers/action_agent/dashboard_controller.rb +104 -0
  27. data/app/controllers/action_agent/traces_controller.rb +121 -0
  28. data/app/jobs/action_agent/agent_execution_job.rb +52 -0
  29. data/app/jobs/action_agent/application_job.rb +12 -0
  30. data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
  31. data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
  32. data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
  33. data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
  34. data/app/jobs/action_agent/trace_retention_job.rb +57 -0
  35. data/app/models/action_agent/agent.rb +343 -0
  36. data/app/models/action_agent/agent_context.rb +129 -0
  37. data/app/models/action_agent/agent_generation.rb +48 -0
  38. data/app/models/action_agent/agent_memory.rb +50 -0
  39. data/app/models/action_agent/agent_memory_entry.rb +14 -0
  40. data/app/models/action_agent/agent_message.rb +43 -0
  41. data/app/models/action_agent/agent_run.rb +151 -0
  42. data/app/models/action_agent/agent_template.rb +182 -0
  43. data/app/models/action_agent/agent_version.rb +48 -0
  44. data/app/models/action_agent/api_key.rb +53 -0
  45. data/app/models/action_agent/application_record.rb +27 -0
  46. data/app/models/action_agent/evaluation.rb +80 -0
  47. data/app/models/action_agent/evaluation_run.rb +20 -0
  48. data/app/models/action_agent/model_pricing.rb +80 -0
  49. data/app/models/action_agent/provider_key.rb +60 -0
  50. data/app/models/action_agent/recording_action.rb +119 -0
  51. data/app/models/action_agent/recording_snapshot.rb +88 -0
  52. data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
  53. data/app/models/action_agent/sandbox_run.rb +45 -0
  54. data/app/models/action_agent/sandbox_session.rb +160 -0
  55. data/app/models/action_agent/session_recording.rb +178 -0
  56. data/app/models/action_agent/telemetry_trace.rb +357 -0
  57. data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
  58. data/app/models/concerns/action_agent/ownable.rb +86 -0
  59. data/app/models/concerns/action_agent/session_recordable.rb +91 -0
  60. data/app/queries/action_agent/agent_executions.rb +201 -0
  61. data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
  62. data/app/serializers/action_agent/interaction_preview.rb +22 -0
  63. data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
  64. data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
  65. data/app/services/action_agent/agent_execution_service.rb +572 -0
  66. data/app/services/action_agent/agent_registrar.rb +197 -0
  67. data/app/services/action_agent/agent_scorecard.rb +191 -0
  68. data/app/services/action_agent/agent_toolbox.rb +504 -0
  69. data/app/services/action_agent/evaluation_runner_service.rb +481 -0
  70. data/app/services/action_agent/mcp_catalog.rb +247 -0
  71. data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
  72. data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
  73. data/app/services/action_agent/playwright_mcp_client.rb +148 -0
  74. data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
  75. data/app/services/action_agent/session_recording_service.rb +228 -0
  76. data/app/services/action_agent/tool_discovery.rb +617 -0
  77. data/app/views/action_agent/dashboard/index.html.erb +5 -0
  78. data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
  79. data/app/views/action_agent/traces/index.html.erb +135 -0
  80. data/app/views/action_agent/traces/metrics.html.erb +145 -0
  81. data/app/views/action_agent/traces/show.html.erb +36 -0
  82. data/app/views/layouts/action_agent/application.html.erb +94 -0
  83. data/app/views/layouts/action_agent/react.html.erb +19 -0
  84. data/config/routes.rb +144 -0
  85. data/lib/action_agent/compatibility.rb +49 -0
  86. data/lib/action_agent/engine.rb +51 -0
  87. data/lib/action_agent/version.rb +5 -0
  88. data/lib/action_agent.rb +388 -0
  89. data/lib/actionagent.rb +6 -0
  90. data/lib/generators/action_agent/install_generator.rb +137 -0
  91. data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
  92. data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
  93. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
  94. data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
  95. metadata +209 -12
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Base class for the dashboard's JSON API — everything the React
6
+ # dashboard reads and writes.
7
+ #
8
+ # Authentication is the host app's (ActionAgent
9
+ # .authentication_method), and so is ownership: `owned` scopes any
10
+ # dashboard relation to the signed-in user or account depending on what
11
+ # the model declared with `owned_by`, which is how a single-user
12
+ # install, a per-user install and a multi-tenant platform all read the
13
+ # same controllers.
14
+ #
15
+ # Note this is not the telemetry ingest endpoint — that authenticates
16
+ # with a bearer token and lives in Api::TracesController.
17
+ class BaseController < ActionAgent::ApplicationController
18
+ skip_forgery_protection
19
+
20
+ rescue_from ActiveRecord::RecordNotFound, with: :not_found
21
+ rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
22
+ rescue_from ActionController::ParameterMissing, with: :bad_request
23
+
24
+ private
25
+
26
+ # Scopes +relation+ to the caller, following the model's own
27
+ # declaration. Unowned models (a single-user install, or a model that
28
+ # nothing owns) come back unfiltered.
29
+ # An unresolved owner scopes to nothing rather than to
30
+ # `where(id: nil)`, which would match every unowned row and leak them
31
+ # across tenants.
32
+ def owned(relation)
33
+ klass = relation.respond_to?(:klass) ? relation.klass : relation
34
+
35
+ case klass.owner_association
36
+ when :account then current_account ? relation.where(account_id: current_account.id) : relation.none
37
+ when :user then current_user ? relation.where(user_id: current_user.id) : relation.none
38
+ else relation.all
39
+ end
40
+ end
41
+
42
+ # The agents the caller can see. Not simply `owned(Agent)`: a host
43
+ # app can define reachability more broadly than ownership (an
44
+ # account's key reaching every member's agents, say).
45
+ def owner_agents
46
+ ActionAgent.agents_for(current_owner)
47
+ end
48
+
49
+ # Reported traces visible to the caller. Scoped to the tenant in a
50
+ # multi-tenant install; every trace otherwise.
51
+ def owned_traces
52
+ ActionAgent.trace_model.for_account(current_account)
53
+ end
54
+
55
+ # The tenant, when the host app has one. current_owner already
56
+ # resolves it in multi-tenant mode; single-tenant installs have none.
57
+ def current_account
58
+ ActionAgent.multi_tenant? ? current_owner : nil
59
+ end
60
+
61
+ # Refuses the request when a multi-tenant install can't resolve a
62
+ # tenant. Single-tenant installs have nothing to check.
63
+ def require_owner!
64
+ return unless ActionAgent.multi_tenant?
65
+ return if current_owner.present?
66
+
67
+ render json: { error: "No account" }, status: :unauthorized
68
+ end
69
+
70
+ # Asks the host app whether this owner may do +kind+ (:execution or
71
+ # :trace_ingest). Unlimited unless the app said otherwise.
72
+ def enforce_quota!(kind)
73
+ denial = ActionAgent.quota_denial(current_owner, kind)
74
+ return if denial.blank?
75
+
76
+ body = { error: "Plan limit reached", upgrade_required: true }
77
+ # A checker can answer with a message, or with a hash carrying
78
+ # whatever else the host app wants the client to see (its own usage
79
+ # numbers, an upgrade link).
80
+ body = denial.is_a?(Hash) ? body.merge(denial) : body.merge(message: denial)
81
+
82
+ render json: body, status: :payment_required
83
+ end
84
+
85
+ def enforce_execution_quota! = enforce_quota!(:execution)
86
+
87
+ def record_execution_usage
88
+ ActionAgent.record_usage(current_owner, :execution)
89
+ end
90
+
91
+ # Execution can be turned off entirely, leaving a read-only
92
+ # observability dashboard.
93
+ def require_execution_enabled!
94
+ return if ActionAgent.execution_enabled?
95
+
96
+ render json: { error: "Agent execution is disabled on this dashboard" }, status: :forbidden
97
+ end
98
+
99
+ def not_found
100
+ render json: { error: "Record not found" }, status: :not_found
101
+ end
102
+
103
+ def unprocessable_entity(exception)
104
+ render json: { error: exception.record.errors.full_messages }, status: :unprocessable_entity
105
+ end
106
+
107
+ def bad_request(exception)
108
+ render json: { error: exception.message }, status: :bad_request
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # CRUD + execution for agent evaluations, backing the dashboard
6
+ # Evaluations view. Scoped to the current user's agents.
7
+ class EvaluationsController < BaseController
8
+ before_action :require_owner!
9
+
10
+ # Default criteria used when none are supplied — all rule-based, so a
11
+ # new evaluation produces real scores without provider credentials.
12
+ DEFAULT_CRITERIA = [
13
+ { "key" => "response_present", "type" => "response_present", "config" => {} },
14
+ { "key" => "response_length", "type" => "min_length", "config" => { "chars" => 40 } },
15
+ { "key" => "latency", "type" => "max_latency_ms", "config" => { "ms" => 5000 } },
16
+ { "key" => "token_budget", "type" => "token_budget", "config" => { "output_tokens" => 1000 } }
17
+ ].freeze
18
+
19
+ # GET /api/evaluations
20
+ # agent_id scopes to one agent. The filter has to happen before the limit:
21
+ # the agent page reads this endpoint, and filtering an account-wide page of
22
+ # 50 client-side hides an agent whose evaluations are not among the account's
23
+ # 50 most recent. The scope is already restricted to the current user's
24
+ # agents, so an id outside it simply returns nothing.
25
+ def index
26
+ scope = evaluations_scope
27
+ scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
28
+ evaluations = scope.includes(:agent, :evaluation_runs).recent.limit(50)
29
+
30
+ render json: { evaluations: evaluations.map { |evaluation| serialize(evaluation) } }
31
+ end
32
+
33
+ # GET /api/evaluations/:id
34
+ def show
35
+ evaluation = evaluations_scope.find(params[:id])
36
+
37
+ render json: {
38
+ evaluation: serialize(evaluation).merge(
39
+ runs: evaluation.evaluation_runs.recent.limit(20).map { |run| serialize_run(run) }
40
+ )
41
+ }
42
+ end
43
+
44
+ # POST /api/evaluations
45
+ def create
46
+ agent = owner_agents.find(params.require(:evaluation)[:agent_id])
47
+
48
+ judge_kind = evaluation_params[:judge_kind].presence || "rules"
49
+ config = {}
50
+ config["compare_models"] = compare_models_param if compare_models_param.any?
51
+
52
+ evaluation = agent.evaluations.new(
53
+ name: evaluation_params[:name],
54
+ judge_kind: judge_kind,
55
+ judge_model: evaluation_params[:judge_model],
56
+ sample_size: evaluation_params[:sample_size].presence || 20,
57
+ # judge_defined starts with no criteria — the judge authors the
58
+ # KPIs on the first run.
59
+ criteria: judge_kind == "judge_defined" ? explicit_criteria : normalized_criteria,
60
+ config: config
61
+ )
62
+
63
+ if evaluation.save
64
+ evaluation.run!
65
+ render json: { evaluation: serialize(evaluation.reload) }, status: :created
66
+ else
67
+ render json: { errors: evaluation.errors.full_messages }, status: :unprocessable_entity
68
+ end
69
+ end
70
+
71
+ # POST /api/evaluations/:id/run
72
+ def run
73
+ evaluation = evaluations_scope.find(params[:id])
74
+ run = evaluation.run!
75
+
76
+ render json: { evaluation: serialize(evaluation.reload), run: serialize_run(run) }
77
+ end
78
+
79
+ # DELETE /api/evaluations/:id
80
+ def destroy
81
+ evaluations_scope.find(params[:id]).destroy!
82
+ head :no_content
83
+ end
84
+
85
+ private
86
+
87
+ def evaluations_scope
88
+ Evaluation.joins(:agent).where(agent: owner_agents)
89
+ end
90
+
91
+ def evaluation_params
92
+ params.require(:evaluation).permit(:agent_id, :name, :judge_kind, :judge_model, :sample_size)
93
+ end
94
+
95
+ def normalized_criteria
96
+ explicit_criteria.presence || DEFAULT_CRITERIA.deep_dup
97
+ end
98
+
99
+ def explicit_criteria
100
+ raw = params[:evaluation][:criteria]
101
+ return [] if raw.blank?
102
+
103
+ raw.map do |criterion|
104
+ criterion.permit(:key, :type, config: {}).to_h.tap do |c|
105
+ c["key"] = c["key"].presence || c["type"]
106
+ c["config"] ||= {}
107
+ end
108
+ end
109
+ end
110
+
111
+ def compare_models_param
112
+ Array(params[:evaluation][:compare_models]).map(&:to_s).reject(&:blank?)
113
+ end
114
+
115
+ def serialize(evaluation)
116
+ latest = evaluation.latest_run
117
+
118
+ {
119
+ id: evaluation.id,
120
+ name: evaluation.name,
121
+ agent: { id: evaluation.agent.id, name: evaluation.agent.name, slug: evaluation.agent.slug },
122
+ judge_kind: evaluation.judge_kind,
123
+ judge_model: evaluation.judge_model,
124
+ criteria: evaluation.criteria,
125
+ compare_models: evaluation.compare_models,
126
+ config: evaluation.config,
127
+ sample_size: evaluation.sample_size,
128
+ created_at: evaluation.created_at.iso8601,
129
+ latest_run: latest ? serialize_run(latest) : nil
130
+ }
131
+ end
132
+
133
+ def serialize_run(run)
134
+ {
135
+ id: run.id,
136
+ status: run.status,
137
+ scores: run.scores,
138
+ average_score: run.average_score,
139
+ samples_evaluated: run.samples_evaluated,
140
+ samples_passed: run.samples_passed,
141
+ error_message: run.error_message,
142
+ completed_at: run.completed_at&.iso8601,
143
+ created_at: run.created_at.iso8601
144
+ }
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ class InstanceTiersController < ApplicationController
6
+ before_action :set_tier, only: [ :show ]
7
+
8
+ # GET /api/instance_tiers
9
+ # List all available instance tiers
10
+ def index
11
+ tiers = SandboxInstanceTier.available
12
+
13
+ # Filter by category if provided
14
+ if params[:category].present?
15
+ tiers = tiers.select { |t| t.category == params[:category] }
16
+ end
17
+
18
+ # Filter by GPU if requested
19
+ if params[:gpu] == "true"
20
+ tiers = tiers.select(&:has_gpu?)
21
+ elsif params[:gpu] == "false"
22
+ tiers = tiers.reject(&:has_gpu?)
23
+ end
24
+
25
+ render json: {
26
+ tiers: tiers.map(&:as_json),
27
+ categories: [
28
+ { id: "free", name: "Free", description: "Basic instances for testing" },
29
+ { id: "pro", name: "Pro", description: "Standard instances for production" },
30
+ { id: "enterprise", name: "Enterprise", description: "High-performance instances" }
31
+ ]
32
+ }
33
+ end
34
+
35
+ # GET /api/instance_tiers/:id
36
+ # Get a specific instance tier
37
+ def show
38
+ render json: { tier: @tier.as_json }
39
+ end
40
+
41
+ # GET /api/instance_tiers/recommend
42
+ # Get recommended tier for a sandbox type
43
+ def recommend
44
+ sandbox_type = params[:sandbox_type] || "default"
45
+
46
+ recommended = case sandbox_type
47
+ when "playwright_mcp"
48
+ SandboxInstanceTier.find(:cpu_medium)
49
+ when "research"
50
+ SandboxInstanceTier.find(:cpu_small)
51
+ when "terminal"
52
+ SandboxInstanceTier.find(:free)
53
+ else
54
+ SandboxInstanceTier.find(:cpu_small)
55
+ end
56
+
57
+ alternatives = SandboxInstanceTier.available.reject { |t| t.id == recommended.id }.take(3)
58
+
59
+ render json: {
60
+ recommended: recommended.as_json,
61
+ alternatives: alternatives.map(&:as_json),
62
+ sandbox_type: sandbox_type
63
+ }
64
+ end
65
+
66
+ # GET /api/instance_tiers/pricing
67
+ # Get pricing comparison
68
+ def pricing
69
+ tiers = SandboxInstanceTier.available
70
+
71
+ render json: {
72
+ cpu_tiers: tiers.select { |t| !t.has_gpu? }.map { |t|
73
+ {
74
+ id: t.id,
75
+ name: t.name,
76
+ specs: t.display_specs,
77
+ hourly: t.hourly_cost.to_f,
78
+ monthly: t.monthly_cost.to_f,
79
+ category: t.category
80
+ }
81
+ },
82
+ gpu_tiers: tiers.select(&:has_gpu?).map { |t|
83
+ {
84
+ id: t.id,
85
+ name: t.name,
86
+ specs: t.display_specs,
87
+ hourly: t.hourly_cost.to_f,
88
+ monthly: t.monthly_cost.to_f,
89
+ category: t.category
90
+ }
91
+ },
92
+ currency: "USD",
93
+ billing_increment: "per second"
94
+ }
95
+ end
96
+
97
+ private
98
+
99
+ def set_tier
100
+ @tier = SandboxInstanceTier.find(params[:id])
101
+ rescue ArgumentError => e
102
+ render json: { error: "Tier not found: #{params[:id]}" }, status: :not_found
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Read API for agent conversation contexts (solid_agent persistence),
6
+ # backing the dashboard Interactions view.
7
+ #
8
+ # A context is one agent's interaction stream (AgentContext +
9
+ # AgentMessages + AgentGenerations, recorded by SolidAgent::HasContext
10
+ # during execution), scoped to the current user's agents.
11
+ class InteractionsController < BaseController
12
+ before_action :require_owner!
13
+
14
+ DEFAULT_LIMIT = 50
15
+ MAX_WINDOW_MINUTES = 60 * 24 * 90
16
+
17
+ # GET /api/interactions
18
+ def index
19
+ limit = params.fetch(:limit, DEFAULT_LIMIT).to_i.clamp(1, 200)
20
+
21
+ contexts = interactions_scope
22
+ .includes(:contextable)
23
+ .then { |scope| window_minutes ? scope.where(updated_at: window_minutes.minutes.ago..) : scope }
24
+ .recent
25
+ .limit(limit)
26
+
27
+ message_counts = AgentMessage.where(agent_context_id: contexts.map(&:id)).group(:agent_context_id).count
28
+ generation_counts = AgentGeneration.where(agent_context_id: contexts.map(&:id)).group(:agent_context_id).count
29
+ # Latest model per context, so list rows can gauge context-window
30
+ # pressure without loading generations.
31
+ # DISTINCT ON would do this in one pass on PostgreSQL but has no
32
+ # portable equivalent, so take the newest generation id per context
33
+ # (they are only ever appended) and read those rows.
34
+ newest_ids = AgentGeneration
35
+ .where(agent_context_id: contexts.map(&:id))
36
+ .group(:agent_context_id)
37
+ .maximum(:id)
38
+ latest_models = AgentGeneration.where(id: newest_ids.values)
39
+ .pluck(:agent_context_id, :model).to_h
40
+
41
+ previews = message_previews(contexts.map(&:id))
42
+
43
+ persisted = contexts.map do |context|
44
+ serialize_context(context).merge(
45
+ source: "platform",
46
+ model: latest_models[context.id],
47
+ message_count: message_counts[context.id] || 0,
48
+ generation_count: generation_counts[context.id] || 0,
49
+ preview: previews[context.id] || { input: nil, output: nil }
50
+ )
51
+ end
52
+
53
+ reported = reported_traces(limit).map { |trace| TraceInteractionSerializer.summary(trace) }
54
+
55
+ render json: {
56
+ interactions: (persisted + reported).sort_by { |row| row[:last_activity_at].to_s }.reverse.first(limit)
57
+ }
58
+ end
59
+
60
+ # GET /api/interactions/:id
61
+ def show
62
+ if (trace_id = params[:id].to_s[/\Atrace-(\d+)\z/, 1])
63
+ trace = owned_traces.find(trace_id)
64
+ return render json: { interaction: TraceInteractionSerializer.detail(trace) }
65
+ end
66
+
67
+ context = interactions_scope.find(params[:id])
68
+
69
+ render json: {
70
+ interaction: serialize_context(context).merge(
71
+ source: "platform",
72
+ instructions: context.instructions,
73
+ messages: context.messages.chronological.map { |message| serialize_message(message) },
74
+ generations: context.generations.order(created_at: :asc).map { |generation| serialize_generation(generation) }
75
+ )
76
+ }
77
+ end
78
+
79
+ private
80
+
81
+ # What each stream opened with and what it finally answered, so a
82
+ # collapsed interaction row says what it was about — the same two lines a
83
+ # collapsed trace row shows. Two grouped queries rather than loading
84
+ # every message: the list is fifty streams deep and only needs the ends
85
+ # of each.
86
+ def message_previews(context_ids)
87
+ return {} if context_ids.empty?
88
+
89
+ first_input = edge_messages(context_ids, "user", :minimum)
90
+ last_output = edge_messages(context_ids, "assistant", :maximum)
91
+
92
+ context_ids.index_with do |id|
93
+ {
94
+ input: InteractionPreview.line(first_input[id]),
95
+ output: InteractionPreview.line(last_output[id])
96
+ }
97
+ end
98
+ end
99
+
100
+ # The first or last message of a role per context. Messages are only ever
101
+ # appended, so the smallest and largest ids are the ends of the stream —
102
+ # which is the portable form of the DISTINCT ON this would be on
103
+ # PostgreSQL, and the same shape the latest-model lookup above uses.
104
+ def edge_messages(context_ids, role, edge)
105
+ scope = AgentMessage
106
+ .where(agent_context_id: context_ids, role: role)
107
+ .where.not(content: [ nil, "" ])
108
+
109
+ ids = scope.group(:agent_context_id).public_send(edge, :id)
110
+ AgentMessage.where(id: ids.values).pluck(:agent_context_id, :content).to_h
111
+ end
112
+
113
+ # Agents executing outside the platform never write solid_agent contexts —
114
+ # they only report traces. Every reported trace is an interaction: one run
115
+ # of one agent. Traces without captured content still show their tool calls,
116
+ # timings, and generation metadata, so filtering on the presence of a
117
+ # prompt would hide most runs (locally: 9 of 11) and make the per-agent
118
+ # counts disagree with Traces for the same window.
119
+ def reported_traces(limit)
120
+ scope = owned_traces.where.not(agent_class: nil)
121
+ scope = scope.where(timestamp: window_minutes.minutes.ago..) if window_minutes
122
+ # Honor the agent filter the persisted side already applies: without
123
+ # this, a per-agent view listed every agent's reported traffic, and an
124
+ # agent whose traces are all SDK-reported showed nothing of its own.
125
+ scope = scope.merge(traces_for_agent(params[:agent_id])) if params[:agent_id].present?
126
+ scope.order(timestamp: :desc).limit(limit)
127
+ end
128
+
129
+ # Traces belong to an agent by foreign key once AgentRegistrar attributes
130
+ # them; older rows predate that, so fall back to the class name.
131
+ def traces_for_agent(agent_id)
132
+ agent = owner_agents.find_by(id: agent_id)
133
+ return ActionAgent.trace_model.none unless agent
134
+
135
+ ActionAgent.trace_model.where(agent_id: agent.id)
136
+ .or(ActionAgent.trace_model.where(agent_id: nil, agent_class: agent.telemetry_agent_class))
137
+ end
138
+
139
+ # The dashboard-wide time window, shared with Traces. Absent means "all".
140
+ def window_minutes
141
+ return @window_minutes if defined?(@window_minutes)
142
+
143
+ raw = params[:minutes].presence
144
+ @window_minutes = raw ? raw.to_i.clamp(1, MAX_WINDOW_MINUTES) : nil
145
+ end
146
+
147
+ def interactions_scope
148
+ agents = owner_agents
149
+ agents = agents.where(id: params[:agent_id]) if params[:agent_id].present?
150
+ AgentContext.for_agents(agents)
151
+ end
152
+
153
+ def serialize_context(context)
154
+ agent = context.contextable
155
+ {
156
+ id: context.id,
157
+ agent_name: context.agent_name,
158
+ action_name: context.action_name,
159
+ display_name: "#{context.agent_name}##{context.action_name}",
160
+ agent: agent.is_a?(Agent) ? { id: agent.id, name: agent.name, slug: agent.slug } : nil,
161
+ tokens: {
162
+ input: context.total_input_tokens,
163
+ output: context.total_output_tokens,
164
+ total: context.total_tokens
165
+ },
166
+ created_at: context.created_at.iso8601,
167
+ last_activity_at: context.updated_at.iso8601
168
+ }
169
+ end
170
+
171
+ def serialize_message(message)
172
+ AgentMessageSerializer.call(message)
173
+ end
174
+
175
+ def serialize_generation(generation)
176
+ {
177
+ id: generation.id,
178
+ model: generation.model,
179
+ provider: generation.provider,
180
+ finish_reason: generation.finish_reason,
181
+ tokens: {
182
+ input: generation.input_tokens,
183
+ output: generation.output_tokens,
184
+ total: generation.total_tokens,
185
+ cached: generation.cached_tokens,
186
+ thinking: generation.reasoning_tokens
187
+ },
188
+ cache_hit: generation.cache_hit?,
189
+ thinking: generation.thinking?,
190
+ duration_seconds: generation.duration_seconds,
191
+ trace_id: generation.trace_id,
192
+ created_at: generation.created_at.iso8601(3)
193
+ }
194
+ end
195
+ end
196
+ end
197
+ end