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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Per-account LLM provider credential (Settings -> Provider API Keys).
|
|
5
|
+
# Generation runs (AgentExecutionService and the evaluation LLM judge)
|
|
6
|
+
# prefer these over the platform's ENV-configured keys, so users can run
|
|
7
|
+
# agents with their own OpenAI/Anthropic/OpenRouter accounts — or point
|
|
8
|
+
# ollama at their own host (e.g. a tunnel to a locally running instance).
|
|
9
|
+
#
|
|
10
|
+
# The credential is encrypted at rest with Active Record Encryption. API
|
|
11
|
+
# keys are never rendered back to the client — only a masked hint; ollama
|
|
12
|
+
# hosts are not secret and are shown in full (see #display_hint).
|
|
13
|
+
class ProviderKey < ApplicationRecord
|
|
14
|
+
# Providers that authenticate with an API key.
|
|
15
|
+
KEY_PROVIDERS = %w[openai anthropic openrouter].freeze
|
|
16
|
+
# Providers addressed by host URL instead of a key.
|
|
17
|
+
HOST_PROVIDERS = %w[ollama].freeze
|
|
18
|
+
PROVIDERS = (KEY_PROVIDERS + HOST_PROVIDERS).freeze
|
|
19
|
+
|
|
20
|
+
include Ownable
|
|
21
|
+
owned_by :account, :user
|
|
22
|
+
|
|
23
|
+
encrypts :credential if ActionAgent.encrypt_credentials
|
|
24
|
+
|
|
25
|
+
validates :provider, presence: true, inclusion: { in: PROVIDERS }
|
|
26
|
+
# One credential per provider per owner; which column that means
|
|
27
|
+
# depends on the configured mode, so it is checked at validation time.
|
|
28
|
+
validate :provider_unique_within_owner
|
|
29
|
+
validates :credential, presence: true, length: { maximum: 500 }
|
|
30
|
+
validates :credential, format: { with: %r{\Ahttps?://\S+\z}, message: "must be an http(s):// URL" },
|
|
31
|
+
if: :host_based?
|
|
32
|
+
|
|
33
|
+
def host_based?
|
|
34
|
+
HOST_PROVIDERS.include?(provider)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Options merged into generate_with for runs owned by this key's owner,
|
|
38
|
+
# overriding the host app's config/active_agent.yml credentials.
|
|
39
|
+
def generation_options
|
|
40
|
+
host_based? ? { host: credential } : { access_token: credential }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# "sk-a…Q2z9" for keys; hosts are shown in full.
|
|
44
|
+
def display_hint
|
|
45
|
+
return credential if host_based?
|
|
46
|
+
|
|
47
|
+
"#{credential.first(4)}…#{credential.last(4)}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def provider_unique_within_owner
|
|
53
|
+
return if provider.blank?
|
|
54
|
+
|
|
55
|
+
siblings = self.class.for_owner(owner)
|
|
56
|
+
siblings = siblings.where.not(id: id) if persisted?
|
|
57
|
+
errors.add(:provider, "has already been taken") if siblings.exists?(provider: provider)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
class RecordingAction < ApplicationRecord
|
|
5
|
+
belongs_to :session_recording
|
|
6
|
+
has_one :snapshot, class_name: "RecordingSnapshot", dependent: :nullify
|
|
7
|
+
|
|
8
|
+
ACTION_TYPES = %w[
|
|
9
|
+
navigate
|
|
10
|
+
click
|
|
11
|
+
type
|
|
12
|
+
scroll
|
|
13
|
+
snapshot
|
|
14
|
+
select
|
|
15
|
+
hover
|
|
16
|
+
drag
|
|
17
|
+
file_upload
|
|
18
|
+
dialog
|
|
19
|
+
evaluate
|
|
20
|
+
wait
|
|
21
|
+
form_fill
|
|
22
|
+
key_press
|
|
23
|
+
focus
|
|
24
|
+
submit
|
|
25
|
+
handoff
|
|
26
|
+
user_action
|
|
27
|
+
completion
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
validates :action_type, presence: true, inclusion: { in: ACTION_TYPES }
|
|
31
|
+
validates :sequence, presence: true, uniqueness: { scope: :session_recording_id }
|
|
32
|
+
validates :timestamp_ms, presence: true
|
|
33
|
+
|
|
34
|
+
scope :ordered, -> { order(:sequence) }
|
|
35
|
+
scope :with_screenshots, -> { where.not(screenshot_key: nil) }
|
|
36
|
+
|
|
37
|
+
# Get the screenshot URL (signed URL from storage)
|
|
38
|
+
def screenshot_url(expires_in: 15.minutes)
|
|
39
|
+
return nil unless screenshot_key.present?
|
|
40
|
+
|
|
41
|
+
SessionRecordingService.signed_url_for(screenshot_key, expires_in: expires_in)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get the DOM snapshot content
|
|
45
|
+
def dom_snapshot_content
|
|
46
|
+
return nil unless dom_snapshot_key.present?
|
|
47
|
+
|
|
48
|
+
SessionRecordingService.fetch_snapshot(dom_snapshot_key)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Format for API response
|
|
52
|
+
def as_json_for_api
|
|
53
|
+
{
|
|
54
|
+
id: id,
|
|
55
|
+
action_type: action_type,
|
|
56
|
+
sequence: sequence,
|
|
57
|
+
timestamp_ms: timestamp_ms,
|
|
58
|
+
selector: selector,
|
|
59
|
+
value: redacted_value,
|
|
60
|
+
screenshot_url: screenshot_url,
|
|
61
|
+
has_dom_snapshot: dom_snapshot_key.present?,
|
|
62
|
+
metadata: safe_metadata,
|
|
63
|
+
created_at: created_at.iso8601
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get browser state at this action (for handoff)
|
|
68
|
+
def browser_state
|
|
69
|
+
{
|
|
70
|
+
url: extract_url,
|
|
71
|
+
form_values: metadata["form_values"],
|
|
72
|
+
scroll_position: metadata["scroll_position"],
|
|
73
|
+
active_element: selector,
|
|
74
|
+
action_type: action_type
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def redacted_value
|
|
81
|
+
return value unless should_redact?
|
|
82
|
+
|
|
83
|
+
"[REDACTED]"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def should_redact?
|
|
87
|
+
return false unless value.present?
|
|
88
|
+
|
|
89
|
+
sensitive_patterns = [
|
|
90
|
+
/password/i,
|
|
91
|
+
/credit.?card/i,
|
|
92
|
+
/cvv/i,
|
|
93
|
+
/ssn/i,
|
|
94
|
+
/social.?security/i,
|
|
95
|
+
/\b\d{16}\b/, # Credit card numbers
|
|
96
|
+
/\b\d{3}-\d{2}-\d{4}\b/ # SSN format
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
selector_is_sensitive = sensitive_patterns.any? { |p| selector&.match?(p) }
|
|
100
|
+
value_is_sensitive = sensitive_patterns.any? { |p| value.match?(p) }
|
|
101
|
+
|
|
102
|
+
selector_is_sensitive || value_is_sensitive
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def safe_metadata
|
|
106
|
+
# Remove any sensitive data from metadata
|
|
107
|
+
metadata.except("password", "credit_card", "cvv", "ssn")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def extract_url
|
|
111
|
+
case action_type
|
|
112
|
+
when "navigate"
|
|
113
|
+
value
|
|
114
|
+
else
|
|
115
|
+
metadata["url"] || metadata["page_url"]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
class RecordingSnapshot < ApplicationRecord
|
|
5
|
+
belongs_to :session_recording
|
|
6
|
+
belongs_to :recording_action, optional: true
|
|
7
|
+
|
|
8
|
+
# Guarded: a host app created with --skip-active-storage (or --minimal)
|
|
9
|
+
# has no has_one_attached, and the gem depends on railties rather than
|
|
10
|
+
# rails, so it may not even have the gem to require. Without this the
|
|
11
|
+
# engine dies during eager load at boot.
|
|
12
|
+
has_one_attached :file if defined?(ActiveStorage)
|
|
13
|
+
|
|
14
|
+
# Whether file attachment is available in this host app.
|
|
15
|
+
def self.attachments_available?
|
|
16
|
+
defined?(ActiveStorage) && method_defined?(:file)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
SNAPSHOT_TYPES = %w[screenshot dom full_page].freeze
|
|
20
|
+
|
|
21
|
+
validates :storage_key, presence: true, uniqueness: true
|
|
22
|
+
validates :snapshot_type, presence: true, inclusion: { in: SNAPSHOT_TYPES }
|
|
23
|
+
|
|
24
|
+
scope :screenshots, -> { where(snapshot_type: "screenshot") }
|
|
25
|
+
scope :dom_snapshots, -> { where(snapshot_type: "dom") }
|
|
26
|
+
scope :ordered, -> { order(:created_at) }
|
|
27
|
+
|
|
28
|
+
# Get a signed URL for the file
|
|
29
|
+
def signed_url(expires_in: 15.minutes)
|
|
30
|
+
return nil unless self.class.attachments_available?
|
|
31
|
+
return nil unless file.attached?
|
|
32
|
+
|
|
33
|
+
file.url(expires_in: expires_in)
|
|
34
|
+
rescue StandardError
|
|
35
|
+
# Fallback if storage not configured
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Store file data. Without Active Storage the row still records the
|
|
40
|
+
# snapshot's metadata; only the blob is skipped.
|
|
41
|
+
def store!(data, filename: nil, content_type: nil)
|
|
42
|
+
content_type ||= infer_content_type
|
|
43
|
+
filename ||= generate_filename
|
|
44
|
+
|
|
45
|
+
if self.class.attachments_available?
|
|
46
|
+
file.attach(
|
|
47
|
+
io: StringIO.new(data),
|
|
48
|
+
filename: filename,
|
|
49
|
+
content_type: content_type
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
update!(file_size_bytes: data.bytesize)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# For API response
|
|
57
|
+
def as_json_for_api
|
|
58
|
+
{
|
|
59
|
+
id: id,
|
|
60
|
+
storage_key: storage_key,
|
|
61
|
+
snapshot_type: snapshot_type,
|
|
62
|
+
width: width,
|
|
63
|
+
height: height,
|
|
64
|
+
file_size_bytes: file_size_bytes,
|
|
65
|
+
url: signed_url,
|
|
66
|
+
created_at: created_at.iso8601
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def infer_content_type
|
|
73
|
+
case snapshot_type
|
|
74
|
+
when "screenshot", "full_page"
|
|
75
|
+
"image/png"
|
|
76
|
+
when "dom"
|
|
77
|
+
"text/html"
|
|
78
|
+
else
|
|
79
|
+
"application/octet-stream"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def generate_filename
|
|
84
|
+
ext = snapshot_type == "dom" ? "html" : "png"
|
|
85
|
+
"#{storage_key.tr('/', '_')}.#{ext}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# SandboxInstanceTier
|
|
5
|
+
#
|
|
6
|
+
# Defines available instance sizes for sandbox sessions.
|
|
7
|
+
# Similar to Google Colab or Hugging Face Spaces hardware tiers.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# tier = SandboxInstanceTier.find(:gpu_t4)
|
|
11
|
+
# tier.cpu_cores # => 4
|
|
12
|
+
# tier.memory_gb # => 16
|
|
13
|
+
# tier.gpu # => "nvidia-t4"
|
|
14
|
+
# tier.hourly_cost # => 0.35
|
|
15
|
+
#
|
|
16
|
+
class SandboxInstanceTier
|
|
17
|
+
include ActiveModel::Model
|
|
18
|
+
include ActiveModel::Attributes
|
|
19
|
+
|
|
20
|
+
attribute :id, :string
|
|
21
|
+
attribute :name, :string
|
|
22
|
+
attribute :description, :string
|
|
23
|
+
attribute :cpu_cores, :integer
|
|
24
|
+
attribute :memory_gb, :integer
|
|
25
|
+
attribute :disk_gb, :integer
|
|
26
|
+
attribute :gpu, :string
|
|
27
|
+
attribute :gpu_memory_gb, :integer
|
|
28
|
+
attribute :hourly_cost, :decimal
|
|
29
|
+
attribute :monthly_cost, :decimal
|
|
30
|
+
attribute :available, :boolean, default: true
|
|
31
|
+
attribute :category, :string # free, pro, enterprise
|
|
32
|
+
|
|
33
|
+
# Instance tier definitions
|
|
34
|
+
# Pricing modeled after Colab/HuggingFace/Lambda Labs
|
|
35
|
+
TIERS = {
|
|
36
|
+
# --- Free Tier ---
|
|
37
|
+
free: {
|
|
38
|
+
id: "free",
|
|
39
|
+
name: "Free",
|
|
40
|
+
description: "Basic CPU instance for testing",
|
|
41
|
+
cpu_cores: 2,
|
|
42
|
+
memory_gb: 4,
|
|
43
|
+
disk_gb: 10,
|
|
44
|
+
gpu: nil,
|
|
45
|
+
gpu_memory_gb: nil,
|
|
46
|
+
hourly_cost: 0.00,
|
|
47
|
+
monthly_cost: 0.00,
|
|
48
|
+
category: "free"
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
# --- CPU Tiers ---
|
|
52
|
+
cpu_small: {
|
|
53
|
+
id: "cpu_small",
|
|
54
|
+
name: "CPU Small",
|
|
55
|
+
description: "2 vCPUs, 8GB RAM",
|
|
56
|
+
cpu_cores: 2,
|
|
57
|
+
memory_gb: 8,
|
|
58
|
+
disk_gb: 20,
|
|
59
|
+
gpu: nil,
|
|
60
|
+
gpu_memory_gb: nil,
|
|
61
|
+
hourly_cost: 0.05,
|
|
62
|
+
monthly_cost: 36.00,
|
|
63
|
+
category: "pro"
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
cpu_medium: {
|
|
67
|
+
id: "cpu_medium",
|
|
68
|
+
name: "CPU Medium",
|
|
69
|
+
description: "4 vCPUs, 16GB RAM",
|
|
70
|
+
cpu_cores: 4,
|
|
71
|
+
memory_gb: 16,
|
|
72
|
+
disk_gb: 50,
|
|
73
|
+
gpu: nil,
|
|
74
|
+
gpu_memory_gb: nil,
|
|
75
|
+
hourly_cost: 0.10,
|
|
76
|
+
monthly_cost: 72.00,
|
|
77
|
+
category: "pro"
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
cpu_large: {
|
|
81
|
+
id: "cpu_large",
|
|
82
|
+
name: "CPU Large",
|
|
83
|
+
description: "8 vCPUs, 32GB RAM",
|
|
84
|
+
cpu_cores: 8,
|
|
85
|
+
memory_gb: 32,
|
|
86
|
+
disk_gb: 100,
|
|
87
|
+
gpu: nil,
|
|
88
|
+
gpu_memory_gb: nil,
|
|
89
|
+
hourly_cost: 0.20,
|
|
90
|
+
monthly_cost: 144.00,
|
|
91
|
+
category: "pro"
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
cpu_xlarge: {
|
|
95
|
+
id: "cpu_xlarge",
|
|
96
|
+
name: "CPU XLarge",
|
|
97
|
+
description: "16 vCPUs, 64GB RAM",
|
|
98
|
+
cpu_cores: 16,
|
|
99
|
+
memory_gb: 64,
|
|
100
|
+
disk_gb: 200,
|
|
101
|
+
gpu: nil,
|
|
102
|
+
gpu_memory_gb: nil,
|
|
103
|
+
hourly_cost: 0.40,
|
|
104
|
+
monthly_cost: 288.00,
|
|
105
|
+
category: "enterprise"
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
# --- GPU Tiers ---
|
|
109
|
+
gpu_t4: {
|
|
110
|
+
id: "gpu_t4",
|
|
111
|
+
name: "GPU T4",
|
|
112
|
+
description: "NVIDIA T4 (16GB VRAM), 4 vCPUs, 16GB RAM",
|
|
113
|
+
cpu_cores: 4,
|
|
114
|
+
memory_gb: 16,
|
|
115
|
+
disk_gb: 50,
|
|
116
|
+
gpu: "nvidia-tesla-t4",
|
|
117
|
+
gpu_memory_gb: 16,
|
|
118
|
+
hourly_cost: 0.35,
|
|
119
|
+
monthly_cost: 252.00,
|
|
120
|
+
category: "pro"
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
gpu_l4: {
|
|
124
|
+
id: "gpu_l4",
|
|
125
|
+
name: "GPU L4",
|
|
126
|
+
description: "NVIDIA L4 (24GB VRAM), 8 vCPUs, 32GB RAM",
|
|
127
|
+
cpu_cores: 8,
|
|
128
|
+
memory_gb: 32,
|
|
129
|
+
disk_gb: 100,
|
|
130
|
+
gpu: "nvidia-l4",
|
|
131
|
+
gpu_memory_gb: 24,
|
|
132
|
+
hourly_cost: 0.70,
|
|
133
|
+
monthly_cost: 504.00,
|
|
134
|
+
category: "pro"
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
gpu_a10g: {
|
|
138
|
+
id: "gpu_a10g",
|
|
139
|
+
name: "GPU A10G",
|
|
140
|
+
description: "NVIDIA A10G (24GB VRAM), 8 vCPUs, 32GB RAM",
|
|
141
|
+
cpu_cores: 8,
|
|
142
|
+
memory_gb: 32,
|
|
143
|
+
disk_gb: 100,
|
|
144
|
+
gpu: "nvidia-a10g",
|
|
145
|
+
gpu_memory_gb: 24,
|
|
146
|
+
hourly_cost: 1.00,
|
|
147
|
+
monthly_cost: 720.00,
|
|
148
|
+
category: "enterprise"
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
gpu_a100_40: {
|
|
152
|
+
id: "gpu_a100_40",
|
|
153
|
+
name: "GPU A100 40GB",
|
|
154
|
+
description: "NVIDIA A100 (40GB VRAM), 12 vCPUs, 85GB RAM",
|
|
155
|
+
cpu_cores: 12,
|
|
156
|
+
memory_gb: 85,
|
|
157
|
+
disk_gb: 200,
|
|
158
|
+
gpu: "nvidia-a100-40gb",
|
|
159
|
+
gpu_memory_gb: 40,
|
|
160
|
+
hourly_cost: 2.50,
|
|
161
|
+
monthly_cost: 1800.00,
|
|
162
|
+
category: "enterprise"
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
gpu_a100_80: {
|
|
166
|
+
id: "gpu_a100_80",
|
|
167
|
+
name: "GPU A100 80GB",
|
|
168
|
+
description: "NVIDIA A100 (80GB VRAM), 12 vCPUs, 170GB RAM",
|
|
169
|
+
cpu_cores: 12,
|
|
170
|
+
memory_gb: 170,
|
|
171
|
+
disk_gb: 200,
|
|
172
|
+
gpu: "nvidia-a100-80gb",
|
|
173
|
+
gpu_memory_gb: 80,
|
|
174
|
+
hourly_cost: 4.00,
|
|
175
|
+
monthly_cost: 2880.00,
|
|
176
|
+
category: "enterprise"
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
# --- High Memory Tiers ---
|
|
180
|
+
highmem_medium: {
|
|
181
|
+
id: "highmem_medium",
|
|
182
|
+
name: "High Memory Medium",
|
|
183
|
+
description: "4 vCPUs, 64GB RAM",
|
|
184
|
+
cpu_cores: 4,
|
|
185
|
+
memory_gb: 64,
|
|
186
|
+
disk_gb: 100,
|
|
187
|
+
gpu: nil,
|
|
188
|
+
gpu_memory_gb: nil,
|
|
189
|
+
hourly_cost: 0.25,
|
|
190
|
+
monthly_cost: 180.00,
|
|
191
|
+
category: "pro"
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
highmem_large: {
|
|
195
|
+
id: "highmem_large",
|
|
196
|
+
name: "High Memory Large",
|
|
197
|
+
description: "8 vCPUs, 128GB RAM",
|
|
198
|
+
cpu_cores: 8,
|
|
199
|
+
memory_gb: 128,
|
|
200
|
+
disk_gb: 200,
|
|
201
|
+
gpu: nil,
|
|
202
|
+
gpu_memory_gb: nil,
|
|
203
|
+
hourly_cost: 0.50,
|
|
204
|
+
monthly_cost: 360.00,
|
|
205
|
+
category: "enterprise"
|
|
206
|
+
}
|
|
207
|
+
}.freeze
|
|
208
|
+
|
|
209
|
+
class << self
|
|
210
|
+
def all
|
|
211
|
+
TIERS.values.map { |attrs| new(attrs) }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def find(id)
|
|
215
|
+
id = id.to_sym
|
|
216
|
+
raise ArgumentError, "Unknown tier: #{id}" unless TIERS.key?(id)
|
|
217
|
+
|
|
218
|
+
new(TIERS[id])
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def available
|
|
222
|
+
all.select(&:available)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def by_category(category)
|
|
226
|
+
all.select { |t| t.category == category.to_s }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def cpu_tiers
|
|
230
|
+
all.select { |t| t.gpu.nil? }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def gpu_tiers
|
|
234
|
+
all.select { |t| t.gpu.present? }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def free_tier
|
|
238
|
+
find(:free)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def default_tier
|
|
242
|
+
find(:cpu_small)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def free?
|
|
247
|
+
hourly_cost.zero?
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def has_gpu?
|
|
251
|
+
gpu.present?
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def display_price
|
|
255
|
+
if free?
|
|
256
|
+
"Free"
|
|
257
|
+
else
|
|
258
|
+
"$#{format('%.2f', hourly_cost)}/hr"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def display_specs
|
|
263
|
+
specs = [ "#{cpu_cores} vCPU", "#{memory_gb}GB RAM", "#{disk_gb}GB disk" ]
|
|
264
|
+
specs << "#{gpu_memory_gb}GB #{gpu_display_name}" if has_gpu?
|
|
265
|
+
specs.join(" | ")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def gpu_display_name
|
|
269
|
+
return nil unless gpu
|
|
270
|
+
|
|
271
|
+
case gpu
|
|
272
|
+
when "nvidia-tesla-t4" then "T4"
|
|
273
|
+
when "nvidia-l4" then "L4"
|
|
274
|
+
when "nvidia-a10g" then "A10G"
|
|
275
|
+
when "nvidia-a100-40gb" then "A100 40GB"
|
|
276
|
+
when "nvidia-a100-80gb" then "A100 80GB"
|
|
277
|
+
else gpu.gsub("nvidia-", "").upcase
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Convert to Incus resource limits
|
|
282
|
+
def to_incus_limits
|
|
283
|
+
limits = {
|
|
284
|
+
"limits.cpu" => cpu_cores.to_s,
|
|
285
|
+
"limits.memory" => "#{memory_gb}GB",
|
|
286
|
+
"limits.processes" => (cpu_cores * 250).to_s
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
# GPU passthrough requires host configuration
|
|
290
|
+
if has_gpu?
|
|
291
|
+
limits["nvidia.runtime"] = "true"
|
|
292
|
+
limits["nvidia.require.cuda"] = "true"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
limits
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Convert to Kubernetes resource spec
|
|
299
|
+
def to_kubernetes_resources
|
|
300
|
+
resources = {
|
|
301
|
+
requests: {
|
|
302
|
+
cpu: "#{cpu_cores * 500}m",
|
|
303
|
+
memory: "#{memory_gb / 2}Gi"
|
|
304
|
+
},
|
|
305
|
+
limits: {
|
|
306
|
+
cpu: cpu_cores.to_s,
|
|
307
|
+
memory: "#{memory_gb}Gi"
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if has_gpu?
|
|
312
|
+
resources[:limits]["nvidia.com/gpu"] = "1"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
resources
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# GCP machine type mapping
|
|
319
|
+
def gcp_machine_type
|
|
320
|
+
if has_gpu?
|
|
321
|
+
# GPU instances use n1 or a2 series
|
|
322
|
+
case gpu
|
|
323
|
+
when "nvidia-a100-40gb", "nvidia-a100-80gb"
|
|
324
|
+
"a2-highgpu-1g"
|
|
325
|
+
else
|
|
326
|
+
"n1-standard-#{cpu_cores}"
|
|
327
|
+
end
|
|
328
|
+
elsif memory_gb > cpu_cores * 8
|
|
329
|
+
# High memory ratio
|
|
330
|
+
"n2-highmem-#{cpu_cores}"
|
|
331
|
+
else
|
|
332
|
+
"n2-standard-#{cpu_cores}"
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# GCP accelerator config
|
|
337
|
+
def gcp_accelerator_config
|
|
338
|
+
return nil unless has_gpu?
|
|
339
|
+
|
|
340
|
+
{
|
|
341
|
+
accelerator_type: gpu,
|
|
342
|
+
accelerator_count: 1
|
|
343
|
+
}
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def as_json(*)
|
|
347
|
+
{
|
|
348
|
+
id: id,
|
|
349
|
+
name: name,
|
|
350
|
+
description: description,
|
|
351
|
+
specs: {
|
|
352
|
+
cpu_cores: cpu_cores,
|
|
353
|
+
memory_gb: memory_gb,
|
|
354
|
+
disk_gb: disk_gb,
|
|
355
|
+
gpu: gpu,
|
|
356
|
+
gpu_memory_gb: gpu_memory_gb
|
|
357
|
+
},
|
|
358
|
+
pricing: {
|
|
359
|
+
hourly_cost: hourly_cost.to_f,
|
|
360
|
+
monthly_cost: monthly_cost.to_f,
|
|
361
|
+
display: display_price
|
|
362
|
+
},
|
|
363
|
+
category: category,
|
|
364
|
+
available: available
|
|
365
|
+
}
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# SandboxRun tracks individual agent task executions within a sandbox container.
|
|
5
|
+
# This model uses SQLite in sandbox mode for isolated, ephemeral storage.
|
|
6
|
+
class SandboxRun < ApplicationRecord
|
|
7
|
+
enum :status, {
|
|
8
|
+
pending: 0,
|
|
9
|
+
running: 1,
|
|
10
|
+
completed: 2,
|
|
11
|
+
failed: 3,
|
|
12
|
+
cancelled: 4
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
validates :task, presence: true
|
|
16
|
+
validates :status, presence: true
|
|
17
|
+
|
|
18
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
19
|
+
scope :completed_runs, -> { where(status: [ :completed, :failed ]) }
|
|
20
|
+
|
|
21
|
+
# Summary for API responses
|
|
22
|
+
def summary
|
|
23
|
+
{
|
|
24
|
+
id: id,
|
|
25
|
+
task: task.truncate(100),
|
|
26
|
+
status: status,
|
|
27
|
+
duration_ms: duration_ms,
|
|
28
|
+
tokens_used: tokens_used,
|
|
29
|
+
created_at: created_at&.iso8601,
|
|
30
|
+
completed_at: completed_at&.iso8601
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Detailed info including full result
|
|
35
|
+
def details
|
|
36
|
+
summary.merge(
|
|
37
|
+
task: task,
|
|
38
|
+
result: result,
|
|
39
|
+
error: error,
|
|
40
|
+
screenshots: screenshots || [],
|
|
41
|
+
started_at: started_at&.iso8601
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|