actionagent 0.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +103 -0
- data/app/assets/builds/action_agent.css +2 -0
- data/app/assets/builds/action_agent.js +163 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
- data/app/controllers/action_agent/api/agents_controller.rb +411 -0
- data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
- data/app/controllers/action_agent/api/base_controller.rb +112 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
- data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
- data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
- data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
- data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
- data/app/controllers/action_agent/api/templates_controller.rb +94 -0
- data/app/controllers/action_agent/api/tools_controller.rb +58 -0
- data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
- data/app/controllers/action_agent/api/traces_controller.rb +136 -0
- data/app/controllers/action_agent/application_controller.rb +105 -0
- data/app/controllers/action_agent/dashboard_controller.rb +104 -0
- data/app/controllers/action_agent/traces_controller.rb +121 -0
- data/app/jobs/action_agent/agent_execution_job.rb +52 -0
- data/app/jobs/action_agent/application_job.rb +12 -0
- data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
- data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
- data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
- data/app/jobs/action_agent/trace_retention_job.rb +57 -0
- data/app/models/action_agent/agent.rb +343 -0
- data/app/models/action_agent/agent_context.rb +129 -0
- data/app/models/action_agent/agent_generation.rb +48 -0
- data/app/models/action_agent/agent_memory.rb +50 -0
- data/app/models/action_agent/agent_memory_entry.rb +14 -0
- data/app/models/action_agent/agent_message.rb +43 -0
- data/app/models/action_agent/agent_run.rb +151 -0
- data/app/models/action_agent/agent_template.rb +182 -0
- data/app/models/action_agent/agent_version.rb +48 -0
- data/app/models/action_agent/api_key.rb +53 -0
- data/app/models/action_agent/application_record.rb +27 -0
- data/app/models/action_agent/evaluation.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +20 -0
- data/app/models/action_agent/model_pricing.rb +80 -0
- data/app/models/action_agent/provider_key.rb +60 -0
- data/app/models/action_agent/recording_action.rb +119 -0
- data/app/models/action_agent/recording_snapshot.rb +88 -0
- data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
- data/app/models/action_agent/sandbox_run.rb +45 -0
- data/app/models/action_agent/sandbox_session.rb +160 -0
- data/app/models/action_agent/session_recording.rb +178 -0
- data/app/models/action_agent/telemetry_trace.rb +357 -0
- data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
- data/app/models/concerns/action_agent/ownable.rb +86 -0
- data/app/models/concerns/action_agent/session_recordable.rb +91 -0
- data/app/queries/action_agent/agent_executions.rb +201 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
- data/app/serializers/action_agent/interaction_preview.rb +22 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
- data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
- data/app/services/action_agent/agent_execution_service.rb +572 -0
- data/app/services/action_agent/agent_registrar.rb +197 -0
- data/app/services/action_agent/agent_scorecard.rb +191 -0
- data/app/services/action_agent/agent_toolbox.rb +504 -0
- data/app/services/action_agent/evaluation_runner_service.rb +481 -0
- data/app/services/action_agent/mcp_catalog.rb +247 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
- data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
- data/app/services/action_agent/playwright_mcp_client.rb +148 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
- data/app/services/action_agent/session_recording_service.rb +228 -0
- data/app/services/action_agent/tool_discovery.rb +617 -0
- data/app/views/action_agent/dashboard/index.html.erb +5 -0
- data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
- data/app/views/action_agent/traces/index.html.erb +135 -0
- data/app/views/action_agent/traces/metrics.html.erb +145 -0
- data/app/views/action_agent/traces/show.html.erb +36 -0
- data/app/views/layouts/action_agent/application.html.erb +94 -0
- data/app/views/layouts/action_agent/react.html.erb +19 -0
- data/config/routes.rb +144 -0
- data/lib/action_agent/compatibility.rb +49 -0
- data/lib/action_agent/engine.rb +51 -0
- data/lib/action_agent/version.rb +5 -0
- data/lib/action_agent.rb +388 -0
- data/lib/actionagent.rb +6 -0
- data/lib/generators/action_agent/install_generator.rb +137 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
- data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
- data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
- metadata +209 -12
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# Telemetry ingestion endpoint.
|
|
6
|
+
#
|
|
7
|
+
# Receives traces from ActiveAgent::Telemetry::Reporter and stores them
|
|
8
|
+
# for analysis and visualization in the dashboard.
|
|
9
|
+
#
|
|
10
|
+
# Supports two modes:
|
|
11
|
+
# - Local mode: synchronous processing; unauthenticated unless
|
|
12
|
+
# ActionAgent.ingest_api_key is set (set it whenever the
|
|
13
|
+
# mount is reachable beyond your own machine)
|
|
14
|
+
# - Multi-tenant mode: per-account Bearer token auth, async processing
|
|
15
|
+
# via job
|
|
16
|
+
#
|
|
17
|
+
# @example Local mode request
|
|
18
|
+
# POST <mount>/api/traces (e.g. /activeagents/api/traces)
|
|
19
|
+
# Content-Type: application/json
|
|
20
|
+
#
|
|
21
|
+
# {
|
|
22
|
+
# "traces": [...],
|
|
23
|
+
# "sdk": { "name": "activeagent", "version": "0.5.0" }
|
|
24
|
+
# }
|
|
25
|
+
#
|
|
26
|
+
# @example Multi-tenant mode request
|
|
27
|
+
# POST <mount>/api/traces (e.g. /activeagents/api/traces)
|
|
28
|
+
# Authorization: Bearer <api_key>
|
|
29
|
+
# Content-Type: application/json
|
|
30
|
+
#
|
|
31
|
+
# {
|
|
32
|
+
# "traces": [...],
|
|
33
|
+
# "sdk": { "name": "activeagent", "version": "0.5.0" }
|
|
34
|
+
# }
|
|
35
|
+
#
|
|
36
|
+
class TracesController < ActionController::API
|
|
37
|
+
before_action :authenticate_api_key!, if: -> { ActionAgent.multi_tenant? }
|
|
38
|
+
before_action :authenticate_ingest_key!, unless: -> { ActionAgent.multi_tenant? }
|
|
39
|
+
|
|
40
|
+
# Maximum traces accepted per request (mirrors
|
|
41
|
+
# ProcessTelemetryTracesJob::MAX_TRACES_PER_JOB).
|
|
42
|
+
MAX_TRACES_PER_REQUEST = 100
|
|
43
|
+
|
|
44
|
+
# POST <mount>/api/traces (e.g. /activeagents/api/traces)
|
|
45
|
+
def create
|
|
46
|
+
traces = Array(params[:traces]).take(MAX_TRACES_PER_REQUEST)
|
|
47
|
+
sdk_info = params[:sdk] || {}
|
|
48
|
+
|
|
49
|
+
return head :accepted if traces.empty?
|
|
50
|
+
|
|
51
|
+
if ActionAgent.multi_tenant?
|
|
52
|
+
# Multi-tenant mode: process in background
|
|
53
|
+
ActionAgent::ProcessTelemetryTracesJob.perform_later(
|
|
54
|
+
account_id: @account&.id,
|
|
55
|
+
traces: traces.as_json,
|
|
56
|
+
sdk_info: sdk_info.as_json,
|
|
57
|
+
received_at: Time.current.iso8601(6)
|
|
58
|
+
)
|
|
59
|
+
else
|
|
60
|
+
# Local mode: process synchronously
|
|
61
|
+
process_traces_synchronously(traces, sdk_info)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
head :accepted
|
|
65
|
+
rescue ActionController::ParameterMissing => e
|
|
66
|
+
render json: { error: e.message }, status: :bad_request
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Rails.logger.error("[ActionAgent] Trace ingestion error: #{e.message}")
|
|
69
|
+
render json: { error: "Internal server error" }, status: :internal_server_error
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Authenticates the request using Bearer token from Authorization header.
|
|
75
|
+
# Only used in multi-tenant mode.
|
|
76
|
+
def authenticate_api_key!
|
|
77
|
+
token = extract_bearer_token
|
|
78
|
+
|
|
79
|
+
if token.blank?
|
|
80
|
+
render json: { error: "Missing Authorization header" }, status: :unauthorized
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
account_class = ActionAgent.account_class.constantize
|
|
85
|
+
@account = account_class.find_by(telemetry_api_key: token)
|
|
86
|
+
|
|
87
|
+
if @account.nil?
|
|
88
|
+
render json: { error: "Invalid API key" }, status: :unauthorized
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Track usage for rate limiting (if the account responds to it)
|
|
93
|
+
@account.increment_telemetry_usage! if @account.respond_to?(:increment_telemetry_usage!)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Requires the configured single-tenant ingest key when one is set.
|
|
97
|
+
# The telemetry reporter and ruby_llm_telemetry both send their
|
|
98
|
+
# api_key as a Bearer header, so remote apps work unchanged.
|
|
99
|
+
def authenticate_ingest_key!
|
|
100
|
+
expected = ActionAgent.ingest_api_key
|
|
101
|
+
return if expected.blank?
|
|
102
|
+
|
|
103
|
+
token = extract_bearer_token
|
|
104
|
+
return if token.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
|
|
105
|
+
|
|
106
|
+
render json: { error: "Invalid API key" }, status: :unauthorized
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Extracts Bearer token from Authorization header.
|
|
110
|
+
def extract_bearer_token
|
|
111
|
+
auth_header = request.headers["Authorization"]
|
|
112
|
+
return nil if auth_header.blank?
|
|
113
|
+
|
|
114
|
+
match = auth_header.match(/^Bearer\s+(.+)$/i)
|
|
115
|
+
match[1] if match
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Process traces synchronously for local development.
|
|
119
|
+
def process_traces_synchronously(traces, sdk_info)
|
|
120
|
+
model = ActionAgent.trace_model
|
|
121
|
+
|
|
122
|
+
traces.each do |trace|
|
|
123
|
+
# Skip if trace already exists (idempotency)
|
|
124
|
+
next if model.exists?(trace_id: trace["trace_id"])
|
|
125
|
+
|
|
126
|
+
model.create_from_payload(trace, sdk_info)
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
Rails.logger.error(
|
|
129
|
+
"[ActionAgent] Failed to process trace #{trace['trace_id']}: " \
|
|
130
|
+
"#{e.class} - #{e.message}"
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Base controller for the ActiveAgent Dashboard.
|
|
5
|
+
#
|
|
6
|
+
# Handles authentication and provides helper methods for multi-tenant mode.
|
|
7
|
+
class ApplicationController < ActionController::Base
|
|
8
|
+
protect_from_forgery with: :exception
|
|
9
|
+
|
|
10
|
+
before_action :authenticate_dashboard!
|
|
11
|
+
|
|
12
|
+
# Use custom layout if configured, otherwise use engine layout
|
|
13
|
+
layout -> { ActionAgent.layout || "action_agent/application" }
|
|
14
|
+
|
|
15
|
+
helper_method :current_user, :current_owner
|
|
16
|
+
|
|
17
|
+
# Opts an action out of the host app's authentication — for the handful
|
|
18
|
+
# of endpoints that are deliberately public (the template gallery, the
|
|
19
|
+
# demo sandbox). Mirrors the Rails 8 authentication generator's helper
|
|
20
|
+
# so controllers read the same either side of the extraction.
|
|
21
|
+
def self.allow_unauthenticated_access(**options)
|
|
22
|
+
skip_before_action :authenticate_dashboard!, **options
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def authenticate_dashboard!
|
|
28
|
+
if ActionAgent.authentication_method.nil?
|
|
29
|
+
# Traces contain prompts, outputs, and error backtraces. Refuse to
|
|
30
|
+
# serve them unauthenticated anywhere but a local development or
|
|
31
|
+
# test environment — a staging or review-app deployment runs under
|
|
32
|
+
# its own RAILS_ENV and is just as reachable as production.
|
|
33
|
+
unless Rails.env.local?
|
|
34
|
+
render plain: "ActiveAgent Dashboard: set ActionAgent.authentication_method " \
|
|
35
|
+
"(see docs/framework/dashboard.md) to enable access in production.",
|
|
36
|
+
status: :forbidden
|
|
37
|
+
end
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
result = ActionAgent.authentication_method.call(self)
|
|
42
|
+
head :unauthorized unless result
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
Rails.logger.error("[ActionAgent] Authentication error: #{e.message}")
|
|
45
|
+
head :unauthorized
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns the current user from the host application.
|
|
49
|
+
def current_user
|
|
50
|
+
return @current_user if defined?(@current_user)
|
|
51
|
+
|
|
52
|
+
@current_user = resolve_actor(
|
|
53
|
+
ActionAgent.current_user_resolver,
|
|
54
|
+
ActionAgent.current_user_method,
|
|
55
|
+
:current_user
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the current owner (account in multi-tenant, user otherwise).
|
|
60
|
+
def current_owner
|
|
61
|
+
# No fallback to the user in multi-tenant mode: a signed-in user with
|
|
62
|
+
# no tenant owns nothing here, and quietly substituting them would
|
|
63
|
+
# hand them a scope they are not part of.
|
|
64
|
+
if ActionAgent.multi_tenant?
|
|
65
|
+
resolve_actor(
|
|
66
|
+
ActionAgent.current_account_resolver,
|
|
67
|
+
ActionAgent.current_account_method,
|
|
68
|
+
:current_owner
|
|
69
|
+
)
|
|
70
|
+
else
|
|
71
|
+
current_user
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Prefers the host app's lambda. A named method is only called when the
|
|
76
|
+
# controller really responds to it and it isn't the accessor we are
|
|
77
|
+
# already inside — configuring current_user_method = :current_user is
|
|
78
|
+
# the obvious thing to write, and it would otherwise recurse forever.
|
|
79
|
+
#
|
|
80
|
+
# The re-entrancy guard covers the same mistake in lambda form. The
|
|
81
|
+
# engine's controllers are their own base class, so the natural-looking
|
|
82
|
+
# `->(c) { c.current_user }` resolves to *this* method rather than the
|
|
83
|
+
# host app's, and without the guard it recurses until SystemStackError.
|
|
84
|
+
# Degrading to nil turns a 500 into an unresolved owner, which now
|
|
85
|
+
# scopes to nothing rather than to everything.
|
|
86
|
+
def resolve_actor(resolver, method_name, own_name)
|
|
87
|
+
@resolving_actors ||= {}
|
|
88
|
+
return nil if @resolving_actors[own_name]
|
|
89
|
+
|
|
90
|
+
@resolving_actors[own_name] = true
|
|
91
|
+
begin
|
|
92
|
+
return resolver.call(self) if resolver
|
|
93
|
+
|
|
94
|
+
return nil if method_name.nil? || method_name.to_sym == own_name
|
|
95
|
+
return nil unless respond_to?(method_name, true)
|
|
96
|
+
|
|
97
|
+
send(method_name)
|
|
98
|
+
ensure
|
|
99
|
+
@resolving_actors[own_name] = false
|
|
100
|
+
end
|
|
101
|
+
rescue NoMethodError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Serves the React dashboard.
|
|
5
|
+
#
|
|
6
|
+
# Every path under the mount renders this one action; routing below it
|
|
7
|
+
# happens in the browser. Initial state is handed over as a JSON data
|
|
8
|
+
# attribute rather than through Inertia, so the engine works in any host
|
|
9
|
+
# app without adding a frontend framework to it.
|
|
10
|
+
class DashboardController < ApplicationController
|
|
11
|
+
# The React dashboard brings its own chrome, so it does not use the
|
|
12
|
+
# server-rendered layout the traces views share.
|
|
13
|
+
layout -> { ActionAgent.layout || "action_agent/react" }
|
|
14
|
+
|
|
15
|
+
def index
|
|
16
|
+
render "action_agent/dashboard/index", locals: { props: props }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def props
|
|
22
|
+
{
|
|
23
|
+
user: current_user_props,
|
|
24
|
+
account: current_account_props,
|
|
25
|
+
initialAgents: serialize_agents(owner_agents.order(updated_at: :desc).limit(20)),
|
|
26
|
+
meta: meta_props,
|
|
27
|
+
mountPath: active_agent_dashboard_mount_path,
|
|
28
|
+
# Billing is the host app's business; the dashboard renders no
|
|
29
|
+
# subscription chrome unless it is told about one.
|
|
30
|
+
subscription: nil
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def owner_agents
|
|
35
|
+
ActionAgent.agents_for(current_owner)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def meta_props
|
|
39
|
+
{
|
|
40
|
+
activeagentVersion: ActiveAgent::VERSION,
|
|
41
|
+
providers: Agent::PROVIDERS,
|
|
42
|
+
presetTypes: Agent::PRESET_TYPES,
|
|
43
|
+
instructionSets: Agent::INSTRUCTION_SETS,
|
|
44
|
+
availableTools: Agent::AVAILABLE_TOOLS,
|
|
45
|
+
executionEnabled: ActionAgent.execution_enabled?,
|
|
46
|
+
multiTenant: ActionAgent.multi_tenant?,
|
|
47
|
+
upgradeUrl: ActionAgent.upgrade_url
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def serialize_agents(agents)
|
|
52
|
+
agents = agents.to_a
|
|
53
|
+
scorecards = AgentScorecard.for_agents(agents)
|
|
54
|
+
|
|
55
|
+
agents.map do |agent|
|
|
56
|
+
{
|
|
57
|
+
id: agent.id,
|
|
58
|
+
name: agent.name,
|
|
59
|
+
slug: agent.slug,
|
|
60
|
+
description: agent.description,
|
|
61
|
+
provider: agent.provider,
|
|
62
|
+
model: agent.model,
|
|
63
|
+
status: agent.status,
|
|
64
|
+
presetType: agent.preset_type,
|
|
65
|
+
appearance: agent.appearance,
|
|
66
|
+
versionCount: agent.version_count,
|
|
67
|
+
createdAt: agent.created_at,
|
|
68
|
+
updatedAt: agent.updated_at,
|
|
69
|
+
stats: scorecards[agent.id]
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
rescue ActiveRecord::StatementInvalid
|
|
73
|
+
# Telemetry-only installs have the traces table but not the rest of
|
|
74
|
+
# the dashboard schema; the app still boots and shows an empty list.
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def current_user_props
|
|
79
|
+
return { name: "Guest" } unless current_user
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
id: current_user.id,
|
|
83
|
+
name: current_user.try(:display_name) || current_user.try(:name) || current_user.try(:email_address),
|
|
84
|
+
email: current_user.try(:email_address) || current_user.try(:email)
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def current_account_props
|
|
89
|
+
return nil unless current_owner
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
id: current_owner.id,
|
|
93
|
+
name: current_owner.try(:name),
|
|
94
|
+
telemetry_api_key: current_owner.try(:telemetry_api_key)
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Where the engine is mounted, so the React app can build API URLs
|
|
99
|
+
# without assuming a path.
|
|
100
|
+
def active_agent_dashboard_mount_path
|
|
101
|
+
request.script_name.presence || "/"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Controller for viewing telemetry traces.
|
|
5
|
+
#
|
|
6
|
+
# Provides:
|
|
7
|
+
# - List view with filtering and pagination
|
|
8
|
+
# - Detail view with span timeline
|
|
9
|
+
# - Metrics overview
|
|
10
|
+
# - Live updates via Turbo Streams
|
|
11
|
+
class TracesController < ApplicationController
|
|
12
|
+
def index
|
|
13
|
+
@traces = fetch_traces
|
|
14
|
+
@metrics = calculate_metrics
|
|
15
|
+
|
|
16
|
+
respond_to do |format|
|
|
17
|
+
format.html
|
|
18
|
+
# turbo-rails is optional; responding to an unregistered MIME
|
|
19
|
+
# type raises in host apps without it.
|
|
20
|
+
format.turbo_stream if turbo_stream_available?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show
|
|
25
|
+
@trace = scoped_traces.find(params[:id])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def metrics
|
|
29
|
+
@metrics = calculate_metrics
|
|
30
|
+
@agent_stats = agent_statistics
|
|
31
|
+
@time_series = time_series_data
|
|
32
|
+
|
|
33
|
+
respond_to do |format|
|
|
34
|
+
format.html
|
|
35
|
+
format.turbo_stream if turbo_stream_available?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def turbo_stream_available?
|
|
42
|
+
Mime::Type.lookup_by_extension(:turbo_stream).present?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# All queries honor the configured trace model and, in multi-tenant
|
|
46
|
+
# mode, the current owner's account (for_account no-ops otherwise).
|
|
47
|
+
#
|
|
48
|
+
# In multi-tenant mode an unresolvable owner returns nothing rather
|
|
49
|
+
# than falling through: current_owner degrades to current_user when
|
|
50
|
+
# current_account_method is unset, which would scope by a user id
|
|
51
|
+
# against account_id — another tenant's traces.
|
|
52
|
+
def scoped_traces
|
|
53
|
+
model = ActionAgent.trace_model
|
|
54
|
+
return model.none if ActionAgent.multi_tenant? && current_owner.nil?
|
|
55
|
+
|
|
56
|
+
model.for_account(current_owner)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fetch_traces
|
|
60
|
+
traces = scoped_traces.recent
|
|
61
|
+
|
|
62
|
+
traces = traces.for_agent(params[:agent]) if params[:agent].present?
|
|
63
|
+
traces = traces.with_errors if params[:status] == "error"
|
|
64
|
+
traces = traces.for_service(params[:service]) if params[:service].present?
|
|
65
|
+
|
|
66
|
+
if params[:start_date].present? && params[:end_date].present?
|
|
67
|
+
traces = traces.for_date_range(
|
|
68
|
+
Time.parse(params[:start_date]),
|
|
69
|
+
Time.parse(params[:end_date])
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
traces.limit(params[:limit] || 50)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def calculate_metrics
|
|
77
|
+
traces = scoped_traces.where(
|
|
78
|
+
"created_at > ?", 24.hours.ago
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
total_traces: traces.count,
|
|
83
|
+
total_tokens: traces.sum(:total_input_tokens) + traces.sum(:total_output_tokens),
|
|
84
|
+
avg_duration_ms: traces.average(:total_duration_ms)&.round(2) || 0,
|
|
85
|
+
error_rate: calculate_error_rate(traces),
|
|
86
|
+
unique_agents: traces.distinct.count(:agent_class)
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def calculate_error_rate(traces)
|
|
91
|
+
total = traces.count
|
|
92
|
+
return 0.0 if total.zero?
|
|
93
|
+
|
|
94
|
+
errors = traces.with_errors.count
|
|
95
|
+
((errors.to_f / total) * 100).round(2)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def agent_statistics
|
|
99
|
+
scoped_traces
|
|
100
|
+
.where("created_at > ?", 24.hours.ago)
|
|
101
|
+
.group(:agent_class)
|
|
102
|
+
.select(
|
|
103
|
+
"agent_class",
|
|
104
|
+
"COUNT(*) as trace_count",
|
|
105
|
+
"SUM(total_input_tokens + total_output_tokens) as total_tokens",
|
|
106
|
+
"AVG(total_duration_ms) as avg_duration",
|
|
107
|
+
"SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) as error_count"
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def time_series_data
|
|
112
|
+
scoped_traces
|
|
113
|
+
.where("created_at > ?", 1.hour.ago)
|
|
114
|
+
.group_by_minute(:created_at)
|
|
115
|
+
.count
|
|
116
|
+
rescue NoMethodError
|
|
117
|
+
# Fallback if groupdate gem not available
|
|
118
|
+
{}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
class AgentExecutionJob < ApplicationJob
|
|
5
|
+
queue_as :agents
|
|
6
|
+
|
|
7
|
+
def perform(run_id)
|
|
8
|
+
run = AgentRun.find(run_id)
|
|
9
|
+
return if run.cancelled? || run.complete?
|
|
10
|
+
|
|
11
|
+
run.update!(status: :running, started_at: Time.current)
|
|
12
|
+
run.add_log("Starting execution", level: :info)
|
|
13
|
+
|
|
14
|
+
begin
|
|
15
|
+
agent_record = run.agent
|
|
16
|
+
|
|
17
|
+
# Build the agent class dynamically based on configuration
|
|
18
|
+
result = execute_agent(agent_record, run)
|
|
19
|
+
|
|
20
|
+
run.update!(
|
|
21
|
+
output: result[:output],
|
|
22
|
+
output_metadata: result[:metadata],
|
|
23
|
+
status: :complete,
|
|
24
|
+
completed_at: Time.current,
|
|
25
|
+
duration_ms: ((Time.current - run.started_at) * 1000).to_i,
|
|
26
|
+
input_tokens: result.dig(:usage, :input_tokens),
|
|
27
|
+
output_tokens: result.dig(:usage, :output_tokens),
|
|
28
|
+
total_tokens: result.dig(:usage, :total_tokens)
|
|
29
|
+
)
|
|
30
|
+
run.add_log("Execution completed successfully", level: :info)
|
|
31
|
+
|
|
32
|
+
rescue => e
|
|
33
|
+
run.update!(
|
|
34
|
+
status: :failed,
|
|
35
|
+
completed_at: Time.current,
|
|
36
|
+
error_message: e.message,
|
|
37
|
+
error_backtrace: e.backtrace&.first(10)&.join("\n")
|
|
38
|
+
)
|
|
39
|
+
run.add_log("Execution failed: #{e.message}", level: :error)
|
|
40
|
+
raise
|
|
41
|
+
ensure
|
|
42
|
+
run.broadcast_update
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def execute_agent(agent_record, run)
|
|
49
|
+
AgentExecutionService.call(agent_record, run)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Base class for all Dashboard engine jobs.
|
|
5
|
+
class ApplicationJob < ActiveJob::Base
|
|
6
|
+
# Retry failed jobs
|
|
7
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 3
|
|
8
|
+
|
|
9
|
+
# Discard jobs for records that no longer exist
|
|
10
|
+
discard_on ActiveRecord::RecordNotFound
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Processes telemetry traces received from ActiveAgent clients.
|
|
5
|
+
#
|
|
6
|
+
# This job handles the asynchronous processing of trace data to avoid
|
|
7
|
+
# blocking the ingestion endpoint. It:
|
|
8
|
+
# - Creates TelemetryTrace records for each trace
|
|
9
|
+
# - Updates aggregate statistics
|
|
10
|
+
# - Handles any errors gracefully
|
|
11
|
+
#
|
|
12
|
+
# @example Local mode
|
|
13
|
+
# ActionAgent::ProcessTelemetryTracesJob.perform_later(
|
|
14
|
+
# traces: [...],
|
|
15
|
+
# sdk_info: { name: "activeagent", version: "0.5.0" },
|
|
16
|
+
# received_at: "2024-01-15T10:30:00Z"
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# @example Multi-tenant mode
|
|
20
|
+
# ActionAgent::ProcessTelemetryTracesJob.perform_later(
|
|
21
|
+
# account_id: 1,
|
|
22
|
+
# traces: [...],
|
|
23
|
+
# sdk_info: { name: "activeagent", version: "0.5.0" },
|
|
24
|
+
# received_at: "2024-01-15T10:30:00Z"
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
class ProcessTelemetryTracesJob < ::ActiveJob::Base
|
|
28
|
+
queue_as :default
|
|
29
|
+
|
|
30
|
+
# Maximum traces to process in a single job to avoid memory issues
|
|
31
|
+
MAX_TRACES_PER_JOB = 100
|
|
32
|
+
|
|
33
|
+
def perform(account_id: nil, traces:, sdk_info:, received_at:)
|
|
34
|
+
account = resolve_account(account_id)
|
|
35
|
+
|
|
36
|
+
# In multi-tenant mode, require an account
|
|
37
|
+
if ActionAgent.multi_tenant? && account.nil?
|
|
38
|
+
Rails.logger.warn("[ProcessTelemetryTracesJob] Skipping traces - no valid account")
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Process in bounded slices; anything beyond the cap is re-enqueued
|
|
43
|
+
# rather than silently dropped (the client already received 202).
|
|
44
|
+
remainder = traces.drop(MAX_TRACES_PER_JOB)
|
|
45
|
+
traces = traces.take(MAX_TRACES_PER_JOB)
|
|
46
|
+
if remainder.any?
|
|
47
|
+
self.class.perform_later(
|
|
48
|
+
account_id: account_id, traces: remainder,
|
|
49
|
+
sdk_info: sdk_info, received_at: received_at
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
traces.each do |trace|
|
|
54
|
+
process_trace(trace, sdk_info, account)
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
Rails.logger.error(
|
|
57
|
+
"[ProcessTelemetryTracesJob] Failed to process trace #{trace['trace_id']}: " \
|
|
58
|
+
"#{e.class} - #{e.message}"
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def resolve_account(account_id)
|
|
66
|
+
return nil unless ActionAgent.multi_tenant?
|
|
67
|
+
return nil unless account_id
|
|
68
|
+
|
|
69
|
+
account_class = ActionAgent.account_class.constantize
|
|
70
|
+
account_class.find_by(id: account_id)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def process_trace(trace, sdk_info, account)
|
|
74
|
+
model = ActionAgent.trace_model
|
|
75
|
+
|
|
76
|
+
# Build uniqueness scope
|
|
77
|
+
scope = model.where(trace_id: trace["trace_id"])
|
|
78
|
+
scope = scope.where(account: account) if ActionAgent.multi_tenant? && account
|
|
79
|
+
|
|
80
|
+
# Skip if trace already exists (idempotency)
|
|
81
|
+
return if scope.exists?
|
|
82
|
+
|
|
83
|
+
model.create_from_payload(trace, sdk_info, account: account)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
class SandboxCleanupJob < ApplicationJob
|
|
5
|
+
queue_as :sandboxes
|
|
6
|
+
|
|
7
|
+
# Clean up Cloud Run resources for an expired sandbox
|
|
8
|
+
def perform(sandbox_session_id)
|
|
9
|
+
sandbox = SandboxSession.find_by(id: sandbox_session_id)
|
|
10
|
+
return unless sandbox
|
|
11
|
+
|
|
12
|
+
Rails.logger.info("Cleaning up sandbox: #{sandbox.session_id}")
|
|
13
|
+
|
|
14
|
+
# Delete Cloud Run Job if exists
|
|
15
|
+
if sandbox.cloud_run_job_id.present? && !Rails.env.development?
|
|
16
|
+
delete_cloud_run_job(sandbox.cloud_run_job_id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Optionally delete old sandbox records
|
|
20
|
+
# For now, keep for analytics
|
|
21
|
+
sandbox.update!(cloud_run_url: nil, cloud_run_job_id: nil)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Periodic cleanup of all expired sandboxes
|
|
25
|
+
def self.cleanup_expired!
|
|
26
|
+
SandboxSession.expired_sessions.active.find_each do |sandbox|
|
|
27
|
+
sandbox.expire!
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def delete_cloud_run_job(job_id)
|
|
34
|
+
require "google/cloud/run/v2"
|
|
35
|
+
|
|
36
|
+
client = Google::Cloud::Run::V2::Jobs::Client.new
|
|
37
|
+
client.delete_job(name: job_id)
|
|
38
|
+
rescue => e
|
|
39
|
+
Rails.logger.warn("Failed to delete Cloud Run job #{job_id}: #{e.message}")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|