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.
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 +92 -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 +334 -0
  94. data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +71 -0
  95. 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