actionagent 0.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +103 -0
  4. data/app/assets/builds/action_agent.css +2 -0
  5. data/app/assets/builds/action_agent.js +163 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
  7. data/app/controllers/action_agent/api/agents_controller.rb +411 -0
  8. data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
  9. data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
  10. data/app/controllers/action_agent/api/base_controller.rb +112 -0
  11. data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
  12. data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
  13. data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
  14. data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
  15. data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
  16. data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
  17. data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
  18. data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
  19. data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
  20. data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
  21. data/app/controllers/action_agent/api/templates_controller.rb +94 -0
  22. data/app/controllers/action_agent/api/tools_controller.rb +58 -0
  23. data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
  24. data/app/controllers/action_agent/api/traces_controller.rb +136 -0
  25. data/app/controllers/action_agent/application_controller.rb +105 -0
  26. data/app/controllers/action_agent/dashboard_controller.rb +104 -0
  27. data/app/controllers/action_agent/traces_controller.rb +121 -0
  28. data/app/jobs/action_agent/agent_execution_job.rb +52 -0
  29. data/app/jobs/action_agent/application_job.rb +12 -0
  30. data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
  31. data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
  32. data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
  33. data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
  34. data/app/jobs/action_agent/trace_retention_job.rb +57 -0
  35. data/app/models/action_agent/agent.rb +343 -0
  36. data/app/models/action_agent/agent_context.rb +129 -0
  37. data/app/models/action_agent/agent_generation.rb +48 -0
  38. data/app/models/action_agent/agent_memory.rb +50 -0
  39. data/app/models/action_agent/agent_memory_entry.rb +14 -0
  40. data/app/models/action_agent/agent_message.rb +43 -0
  41. data/app/models/action_agent/agent_run.rb +151 -0
  42. data/app/models/action_agent/agent_template.rb +182 -0
  43. data/app/models/action_agent/agent_version.rb +48 -0
  44. data/app/models/action_agent/api_key.rb +53 -0
  45. data/app/models/action_agent/application_record.rb +27 -0
  46. data/app/models/action_agent/evaluation.rb +80 -0
  47. data/app/models/action_agent/evaluation_run.rb +20 -0
  48. data/app/models/action_agent/model_pricing.rb +80 -0
  49. data/app/models/action_agent/provider_key.rb +60 -0
  50. data/app/models/action_agent/recording_action.rb +119 -0
  51. data/app/models/action_agent/recording_snapshot.rb +88 -0
  52. data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
  53. data/app/models/action_agent/sandbox_run.rb +45 -0
  54. data/app/models/action_agent/sandbox_session.rb +160 -0
  55. data/app/models/action_agent/session_recording.rb +178 -0
  56. data/app/models/action_agent/telemetry_trace.rb +357 -0
  57. data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
  58. data/app/models/concerns/action_agent/ownable.rb +86 -0
  59. data/app/models/concerns/action_agent/session_recordable.rb +91 -0
  60. data/app/queries/action_agent/agent_executions.rb +201 -0
  61. data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
  62. data/app/serializers/action_agent/interaction_preview.rb +22 -0
  63. data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
  64. data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
  65. data/app/services/action_agent/agent_execution_service.rb +572 -0
  66. data/app/services/action_agent/agent_registrar.rb +197 -0
  67. data/app/services/action_agent/agent_scorecard.rb +191 -0
  68. data/app/services/action_agent/agent_toolbox.rb +504 -0
  69. data/app/services/action_agent/evaluation_runner_service.rb +481 -0
  70. data/app/services/action_agent/mcp_catalog.rb +247 -0
  71. data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
  72. data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
  73. data/app/services/action_agent/playwright_mcp_client.rb +148 -0
  74. data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
  75. data/app/services/action_agent/session_recording_service.rb +228 -0
  76. data/app/services/action_agent/tool_discovery.rb +617 -0
  77. data/app/views/action_agent/dashboard/index.html.erb +5 -0
  78. data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
  79. data/app/views/action_agent/traces/index.html.erb +135 -0
  80. data/app/views/action_agent/traces/metrics.html.erb +145 -0
  81. data/app/views/action_agent/traces/show.html.erb +36 -0
  82. data/app/views/layouts/action_agent/application.html.erb +94 -0
  83. data/app/views/layouts/action_agent/react.html.erb +19 -0
  84. data/config/routes.rb +144 -0
  85. data/lib/action_agent/compatibility.rb +49 -0
  86. data/lib/action_agent/engine.rb +51 -0
  87. data/lib/action_agent/version.rb +5 -0
  88. data/lib/action_agent.rb +388 -0
  89. data/lib/actionagent.rb +6 -0
  90. data/lib/generators/action_agent/install_generator.rb +137 -0
  91. data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
  92. data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
  93. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
  94. data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
  95. metadata +209 -12
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # SandboxOrchestrator
5
+ #
6
+ # Unified interface for managing agent sandbox sessions.
7
+ # Supports multiple backends for cloud-agnostic deployment:
8
+ #
9
+ # - incus: Self-hosted Incus containers (any Linux host)
10
+ # - cloud_run: Google Cloud Run Jobs (serverless)
11
+ # - kubernetes: Kubernetes pods (GKE, EKS, self-hosted k8s)
12
+ #
13
+ # Configuration:
14
+ # Set SANDBOX_BACKEND environment variable to choose backend.
15
+ # Default: "incus" for simplicity
16
+ #
17
+ # Usage:
18
+ # orchestrator = SandboxOrchestrator.new
19
+ # result = orchestrator.create_sandbox(session)
20
+ # status = orchestrator.status(container_id)
21
+ # orchestrator.terminate(container_id)
22
+ #
23
+ class SandboxOrchestrator
24
+ # The engine ships only the in-memory backend. Anything that talks to
25
+ # real infrastructure (Incus, Kubernetes, Cloud Run) is registered by
26
+ # the app that operates it, so the engine carries none of those SDKs:
27
+ #
28
+ # ActionAgent.sandbox_backends = {
29
+ # "cloud_run" => "CloudRunService"
30
+ # }
31
+ BUILT_IN_BACKENDS = { "mock" => "ActionAgent::MockSandboxBackend" }.freeze
32
+
33
+ # Backends disagree on what to call each verb. Candidates are tried in
34
+ # order and the first the backend responds to wins, so a host-registered
35
+ # class needs no adapter of its own.
36
+ ADAPTER_METHODS = {
37
+ create: %i[create_sandbox create_sandbox_pod create_sandbox_job],
38
+ status: %i[status container_status pod_status job_status],
39
+ terminate: %i[terminate terminate_pod cancel_job],
40
+ list: %i[list_sandboxes list_sandbox_pods list_jobs],
41
+ cleanup: %i[cleanup_expired cleanup_expired_pods cleanup_expired_jobs]
42
+ }.freeze
43
+
44
+ class UnsupportedBackendError < StandardError; end
45
+
46
+ # All backend names available in this install.
47
+ def self.backends
48
+ BUILT_IN_BACKENDS.merge(ActionAgent.sandbox_backends.to_h.transform_keys(&:to_s))
49
+ end
50
+
51
+ # The backend used when none is named: whatever the host app configured
52
+ # as sandbox_service, falling back to the in-memory one.
53
+ def self.default_backend
54
+ name = ENV["SANDBOX_BACKEND"].presence || ActionAgent.sandbox_service.to_s
55
+ backends.key?(name) ? name : "mock"
56
+ end
57
+
58
+ def initialize(backend: nil)
59
+ @backend_name = (backend || self.class.default_backend).to_s
60
+ class_name = self.class.backends[@backend_name]
61
+ raise UnsupportedBackendError, "Unknown backend: #{@backend_name}" if class_name.nil?
62
+
63
+ @backend = class_name.constantize.new
64
+ end
65
+
66
+ attr_reader :backend_name
67
+
68
+ # Create a new sandbox for the given session
69
+ #
70
+ # @param sandbox_session [SandboxSession] The session to create a sandbox for
71
+ # @param instance_tier [String, Symbol, SandboxInstanceTier] Optional instance tier
72
+ # @return [Hash] Sandbox details including ID/name and URL
73
+ def create_sandbox(sandbox_session, instance_tier: nil)
74
+ # Resolve tier
75
+ tier = resolve_tier(instance_tier)
76
+
77
+ method = adapter_method(:create)
78
+ result = if accepts_instance_tier?(method)
79
+ @backend.public_send(method, sandbox_session, instance_tier: tier)
80
+ else
81
+ @backend.public_send(method, sandbox_session)
82
+ end
83
+
84
+ # Normalize response format across backends
85
+ {
86
+ sandbox_id: result[:container_name] || result[:pod_name] || result[:job_name],
87
+ url: result[:url],
88
+ ip: result[:container_ip] || result[:pod_ip],
89
+ backend: @backend_name,
90
+ instance_tier: result[:instance_tier] || tier&.id,
91
+ resources: result[:resources],
92
+ hourly_cost: result[:hourly_cost] || tier&.hourly_cost&.to_f,
93
+ created_at: result[:created_at] || Time.current
94
+ }
95
+ end
96
+
97
+ # List available instance tiers
98
+ #
99
+ # @param category [String, nil] Optional category filter (free, pro, enterprise)
100
+ # @return [Array<SandboxInstanceTier>] Available tiers
101
+ def available_tiers(category: nil)
102
+ tiers = SandboxInstanceTier.available
103
+ tiers = tiers.select { |t| t.category == category.to_s } if category
104
+ tiers
105
+ end
106
+
107
+ # Get a specific instance tier
108
+ #
109
+ # @param tier_id [String, Symbol] Tier ID
110
+ # @return [SandboxInstanceTier]
111
+ def get_tier(tier_id)
112
+ SandboxInstanceTier.find(tier_id)
113
+ end
114
+
115
+ # Get the status of a sandbox
116
+ #
117
+ # @param sandbox_id [String] The sandbox ID (container name, pod name, etc.)
118
+ # @return [Hash] Sandbox status
119
+ def status(sandbox_id)
120
+ @backend.public_send(adapter_method(:status), sandbox_id)
121
+ end
122
+
123
+ # Terminate a sandbox
124
+ #
125
+ # @param sandbox_id [String] The sandbox ID to terminate
126
+ # @return [Boolean] true if terminated
127
+ def terminate(sandbox_id)
128
+ @backend.public_send(adapter_method(:terminate), sandbox_id)
129
+ end
130
+
131
+ # List all active sandboxes
132
+ #
133
+ # @return [Array<Hash>] List of sandbox statuses
134
+ def list_sandboxes
135
+ @backend.public_send(adapter_method(:list))
136
+ end
137
+
138
+ # Cleanup expired sandboxes
139
+ #
140
+ # @return [Integer] Number of sandboxes cleaned up
141
+ def cleanup_expired
142
+ @backend.public_send(adapter_method(:cleanup))
143
+ end
144
+
145
+ # Check if the backend is healthy
146
+ #
147
+ # @return [Boolean] true if backend is reachable
148
+ def healthy?
149
+ # A backend that can list is reachable; one that cannot is assumed
150
+ # healthy because there is nothing to probe.
151
+ list_sandboxes if ADAPTER_METHODS[:list].any? { |m| @backend.respond_to?(m) }
152
+ true
153
+ rescue => e
154
+ Rails.logger.error("Sandbox backend health check failed: #{e.message}")
155
+ false
156
+ end
157
+
158
+ # Get backend-specific configuration info
159
+ #
160
+ # @return [Hash] Backend configuration
161
+ def backend_info
162
+ {
163
+ name: @backend_name,
164
+ class: @backend.class.name,
165
+ healthy: healthy?,
166
+ features: backend_features
167
+ }
168
+ end
169
+
170
+ private
171
+
172
+ # The backend's method for +verb+, or a clear error naming what it
173
+ # would have to implement.
174
+ def adapter_method(verb)
175
+ ADAPTER_METHODS.fetch(verb).find { |m| @backend.respond_to?(m) } ||
176
+ raise(UnsupportedBackendError,
177
+ "#{@backend.class} implements none of #{ADAPTER_METHODS.fetch(verb).join(', ')}")
178
+ end
179
+
180
+ def accepts_instance_tier?(method)
181
+ @backend.method(method).parameters.any? { |_type, name| name == :instance_tier }
182
+ end
183
+
184
+ def resolve_tier(tier_param)
185
+ return nil unless tier_param
186
+
187
+ if tier_param.is_a?(SandboxInstanceTier)
188
+ tier_param
189
+ else
190
+ SandboxInstanceTier.find(tier_param)
191
+ end
192
+ rescue ArgumentError
193
+ Rails.logger.warn("Unknown instance tier: #{tier_param}, using default")
194
+ SandboxInstanceTier.default_tier
195
+ end
196
+
197
+ def backend_features
198
+ # A host-registered backend can describe itself; otherwise fall back
199
+ # to what the engine knows about the backends it has seen.
200
+ return @backend.features if @backend.respond_to?(:features)
201
+
202
+ case @backend_name
203
+ when "incus"
204
+ {
205
+ cloud_agnostic: true,
206
+ isolation: "namespaces + apparmor",
207
+ networking: "bridge",
208
+ persistent_storage: true,
209
+ live_migration: true,
210
+ self_hosted: true
211
+ }
212
+ when "kubernetes"
213
+ {
214
+ cloud_agnostic: true,
215
+ isolation: "pods + gvisor",
216
+ networking: "cni + network_policies",
217
+ persistent_storage: true,
218
+ live_migration: false,
219
+ self_hosted: true
220
+ }
221
+ when "cloud_run"
222
+ {
223
+ cloud_agnostic: false,
224
+ isolation: "gvisor",
225
+ networking: "vpc_connector",
226
+ persistent_storage: false,
227
+ live_migration: false,
228
+ self_hosted: false
229
+ }
230
+ when "mock"
231
+ {
232
+ cloud_agnostic: true,
233
+ isolation: "none",
234
+ networking: "mock",
235
+ persistent_storage: false,
236
+ live_migration: false,
237
+ self_hosted: true
238
+ }
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Service for managing browser session recordings
5
+ # Captures agent browser interactions for replay and handoff to human users
6
+ class SessionRecordingService
7
+ class RecordingError < StandardError; end
8
+
9
+ attr_reader :recording
10
+
11
+ def initialize(recording = nil)
12
+ @recording = recording
13
+ end
14
+
15
+ # Start a new recording session
16
+ def self.start(agent_run: nil, sandbox_session: nil, name: nil)
17
+ recording = SessionRecording.start!(
18
+ agent_run: agent_run,
19
+ sandbox_session: sandbox_session,
20
+ name: name
21
+ )
22
+
23
+ new(recording)
24
+ end
25
+
26
+ # Find or create a recording for a context
27
+ def self.for_context(agent_run: nil, sandbox_session: nil)
28
+ recording = SessionRecording.recording.find_by(
29
+ agent_run: agent_run,
30
+ sandbox_session: sandbox_session
31
+ )
32
+
33
+ recording ? new(recording) : start(agent_run: agent_run, sandbox_session: sandbox_session)
34
+ end
35
+
36
+ # Record a browser navigation
37
+ def navigate(url:, screenshot: nil)
38
+ record_action(
39
+ action_type: "navigate",
40
+ value: url,
41
+ screenshot: screenshot,
42
+ metadata: { url: url }
43
+ )
44
+ end
45
+
46
+ # Record a click action
47
+ def click(selector:, screenshot: nil, metadata: {})
48
+ record_action(
49
+ action_type: "click",
50
+ selector: selector,
51
+ screenshot: screenshot,
52
+ metadata: metadata.merge(element: selector)
53
+ )
54
+ end
55
+
56
+ # Record a type/input action
57
+ def type(selector:, text:, screenshot: nil, metadata: {})
58
+ record_action(
59
+ action_type: "type",
60
+ selector: selector,
61
+ value: text,
62
+ screenshot: screenshot,
63
+ metadata: metadata
64
+ )
65
+ end
66
+
67
+ # Record a form fill (multiple fields)
68
+ def fill_form(fields:, screenshot: nil)
69
+ record_action(
70
+ action_type: "form_fill",
71
+ value: fields.to_json,
72
+ screenshot: screenshot,
73
+ metadata: { fields: fields, field_count: fields.size }
74
+ )
75
+ end
76
+
77
+ # Record a key press
78
+ def key_press(key:, screenshot: nil)
79
+ record_action(
80
+ action_type: "key_press",
81
+ value: key,
82
+ screenshot: screenshot
83
+ )
84
+ end
85
+
86
+ # Record a scroll action
87
+ def scroll(position:, screenshot: nil)
88
+ record_action(
89
+ action_type: "scroll",
90
+ metadata: { scroll_position: position },
91
+ screenshot: screenshot
92
+ )
93
+ end
94
+
95
+ # Record a hover action
96
+ def hover(selector:, screenshot: nil)
97
+ record_action(
98
+ action_type: "hover",
99
+ selector: selector,
100
+ screenshot: screenshot
101
+ )
102
+ end
103
+
104
+ # Record a select option action
105
+ def select_option(selector:, values:, screenshot: nil)
106
+ record_action(
107
+ action_type: "select",
108
+ selector: selector,
109
+ value: values.join(", "),
110
+ screenshot: screenshot,
111
+ metadata: { values: values }
112
+ )
113
+ end
114
+
115
+ # Record a file upload
116
+ def file_upload(paths:, screenshot: nil)
117
+ record_action(
118
+ action_type: "file_upload",
119
+ value: paths.join(", "),
120
+ screenshot: screenshot,
121
+ metadata: { paths: paths }
122
+ )
123
+ end
124
+
125
+ # Record a dialog interaction
126
+ def dialog(accept:, text: nil, screenshot: nil)
127
+ record_action(
128
+ action_type: "dialog",
129
+ value: text,
130
+ screenshot: screenshot,
131
+ metadata: { accepted: accept, prompt_text: text }
132
+ )
133
+ end
134
+
135
+ # Record a JavaScript evaluation
136
+ def evaluate(function:, result: nil, screenshot: nil)
137
+ record_action(
138
+ action_type: "evaluate",
139
+ value: function,
140
+ screenshot: screenshot,
141
+ metadata: { result: result&.to_s&.truncate(1000) }
142
+ )
143
+ end
144
+
145
+ # Capture a manual snapshot (screenshot + DOM)
146
+ def capture_snapshot(screenshot: nil, dom: nil, full_page: false)
147
+ snapshot_type = full_page ? "full_page" : "snapshot"
148
+
149
+ record_action(
150
+ action_type: "snapshot",
151
+ screenshot: screenshot,
152
+ dom_snapshot: dom,
153
+ metadata: { full_page: full_page }
154
+ )
155
+ end
156
+
157
+ # Capture browser state for handoff
158
+ def capture_handoff_state(url:, cookies: nil, local_storage: nil, session_storage: nil, form_values: nil)
159
+ @recording.update!(
160
+ metadata: @recording.metadata.merge(
161
+ handoff_state: {
162
+ url: url,
163
+ cookies: cookies,
164
+ local_storage: local_storage,
165
+ session_storage: session_storage,
166
+ form_values: form_values,
167
+ captured_at: Time.current.iso8601
168
+ }
169
+ )
170
+ )
171
+ end
172
+
173
+ # Complete the recording
174
+ def complete!
175
+ @recording.complete!
176
+ @recording
177
+ end
178
+
179
+ # Mark recording as failed
180
+ def fail!(error_message = nil)
181
+ @recording.fail!(error_message)
182
+ @recording
183
+ end
184
+
185
+ # Get the recording timeline for playback
186
+ def timeline
187
+ @recording.timeline
188
+ end
189
+
190
+ # Get handoff state for resuming session
191
+ def handoff_state
192
+ @recording.metadata["handoff_state"]
193
+ end
194
+
195
+ # Class method: Generate signed URL for stored snapshot
196
+ def self.signed_url_for(storage_key, expires_in: 15.minutes)
197
+ snapshot = RecordingSnapshot.find_by(storage_key: storage_key)
198
+ snapshot&.signed_url(expires_in: expires_in)
199
+ end
200
+
201
+ # Class method: Fetch snapshot content
202
+ def self.fetch_snapshot(storage_key)
203
+ snapshot = RecordingSnapshot.find_by(storage_key: storage_key)
204
+ return nil unless snapshot&.file&.attached?
205
+
206
+ snapshot.file.download
207
+ rescue StandardError => e
208
+ Rails.logger.error "Failed to fetch snapshot #{storage_key}: #{e.message}"
209
+ nil
210
+ end
211
+
212
+ private
213
+
214
+ def record_action(action_type:, selector: nil, value: nil, screenshot: nil, dom_snapshot: nil, metadata: {})
215
+ raise RecordingError, "No active recording" unless @recording
216
+ raise RecordingError, "Recording already completed" unless @recording.recording?
217
+
218
+ @recording.record_action!(
219
+ action_type: action_type,
220
+ selector: selector,
221
+ value: value,
222
+ screenshot: screenshot,
223
+ dom_snapshot: dom_snapshot,
224
+ metadata: metadata
225
+ )
226
+ end
227
+ end
228
+ end