actionagent 0.0.0 → 1.2.1
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 +92 -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 +334 -0
- data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +71 -0
- 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
|