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,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # The catalog of MCP servers the dashboard knows about.
5
+ #
6
+ # Serves three jobs that would otherwise each need their own list:
7
+ #
8
+ # 1. **Listing** — the MCP Services view shows these as the servers a
9
+ # workspace can connect, whether or not it has used one yet.
10
+ # 2. **Attribution** — telemetry only carries the tool name a provider
11
+ # invoked. When that name is namespaced (+mcp__playwright__…+) the server
12
+ # is explicit; when it isn't, TOOL_HINTS maps well-known bare tool names
13
+ # back to the server that serves them, so a workspace running the
14
+ # reference servers still gets grouped traffic instead of a flat list.
15
+ # 3. **Provisioning** — entries marked +sandbox: true+ can be started inside
16
+ # a sandbox session, using the command recorded here.
17
+ #
18
+ # Detection never depends on this catalog: a server that isn't listed still
19
+ # shows up in the MCP Services view (as +known: false+) the moment a
20
+ # namespaced tool call from it is ingested. The catalog only adds names,
21
+ # descriptions, and the ability to launch.
22
+ class McpCatalog
23
+ # Whether a server can be started inside a sandbox session. Servers that
24
+ # need workspace-specific credentials (github, slack, postgres) are
25
+ # listable and attributable but not launchable from the dashboard — there
26
+ # is nowhere safe to source their secrets from yet.
27
+ SERVERS = [
28
+ {
29
+ key: "playwright",
30
+ name: "Playwright",
31
+ description: "Drives a real browser: navigate pages, snapshot the accessibility tree, click elements.",
32
+ transport: "stdio",
33
+ command: "npx @playwright/mcp@latest",
34
+ package: "@playwright/mcp",
35
+ categories: %w[browser automation],
36
+ docs_url: "https://github.com/microsoft/playwright-mcp",
37
+ sandbox: true,
38
+ sandbox_type: "playwright_mcp",
39
+ # The platform's own Playwright tools (AgentToolbox "playwright_mcp")
40
+ # invoke these bare, without the mcp__ namespace.
41
+ tool_hints: %w[browser_navigate browser_snapshot browser_click browser_type browser_take_screenshot]
42
+ },
43
+ {
44
+ key: "filesystem",
45
+ name: "Filesystem",
46
+ description: "Reads and writes files under an allow-listed set of directories.",
47
+ transport: "stdio",
48
+ command: "npx @modelcontextprotocol/server-filesystem",
49
+ package: "@modelcontextprotocol/server-filesystem",
50
+ categories: %w[files],
51
+ docs_url: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
52
+ sandbox: true,
53
+ sandbox_type: "terminal",
54
+ tool_hints: %w[read_file read_text_file write_file edit_file list_directory directory_tree move_file search_files]
55
+ },
56
+ {
57
+ key: "fetch",
58
+ name: "Fetch",
59
+ description: "Fetches a URL and converts the page to markdown for the model to read.",
60
+ transport: "stdio",
61
+ command: "uvx mcp-server-fetch",
62
+ package: "mcp-server-fetch",
63
+ categories: %w[web],
64
+ docs_url: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
65
+ sandbox: true,
66
+ sandbox_type: "research",
67
+ tool_hints: %w[fetch fetch_url]
68
+ },
69
+ {
70
+ key: "git",
71
+ name: "Git",
72
+ description: "Reads and manipulates a local git repository: status, diff, log, commit.",
73
+ transport: "stdio",
74
+ command: "uvx mcp-server-git",
75
+ package: "mcp-server-git",
76
+ categories: %w[code vcs],
77
+ docs_url: "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
78
+ sandbox: true,
79
+ sandbox_type: "terminal",
80
+ tool_hints: %w[git_status git_diff git_log git_commit git_add git_create_branch]
81
+ },
82
+ {
83
+ key: "memory",
84
+ name: "Memory",
85
+ description: "A persistent knowledge graph the agent can write facts to and recall across runs.",
86
+ transport: "stdio",
87
+ command: "npx @modelcontextprotocol/server-memory",
88
+ package: "@modelcontextprotocol/server-memory",
89
+ categories: %w[memory],
90
+ docs_url: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
91
+ sandbox: true,
92
+ sandbox_type: "terminal",
93
+ tool_hints: %w[create_entities create_relations add_observations search_nodes read_graph]
94
+ },
95
+ {
96
+ key: "sequential-thinking",
97
+ name: "Sequential Thinking",
98
+ description: "Gives the model a scratchpad for multi-step reasoning it can revise as it goes.",
99
+ transport: "stdio",
100
+ command: "npx @modelcontextprotocol/server-sequential-thinking",
101
+ package: "@modelcontextprotocol/server-sequential-thinking",
102
+ categories: %w[reasoning],
103
+ docs_url: "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
104
+ sandbox: true,
105
+ sandbox_type: "research",
106
+ tool_hints: %w[sequentialthinking]
107
+ },
108
+ {
109
+ key: "github",
110
+ name: "GitHub",
111
+ description: "Reads and writes GitHub issues, pull requests, and repository contents.",
112
+ transport: "http",
113
+ url: "https://api.githubcopilot.com/mcp/",
114
+ categories: %w[code vcs],
115
+ docs_url: "https://github.com/github/github-mcp-server",
116
+ sandbox: false,
117
+ requires_credentials: [ "GITHUB_TOKEN" ],
118
+ tool_hints: %w[create_issue get_issue list_issues create_pull_request get_file_contents search_code]
119
+ },
120
+ {
121
+ key: "slack",
122
+ name: "Slack",
123
+ description: "Posts messages and reads channel history in a connected Slack workspace.",
124
+ transport: "stdio",
125
+ command: "npx @modelcontextprotocol/server-slack",
126
+ package: "@modelcontextprotocol/server-slack",
127
+ categories: %w[communication],
128
+ docs_url: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack",
129
+ sandbox: false,
130
+ requires_credentials: [ "SLACK_BOT_TOKEN", "SLACK_TEAM_ID" ],
131
+ tool_hints: %w[slack_post_message slack_list_channels slack_get_channel_history]
132
+ },
133
+ {
134
+ key: "postgres",
135
+ name: "Postgres",
136
+ description: "Runs read-only SQL against a Postgres database and inspects its schema.",
137
+ transport: "stdio",
138
+ command: "npx @modelcontextprotocol/server-postgres",
139
+ package: "@modelcontextprotocol/server-postgres",
140
+ categories: %w[data],
141
+ docs_url: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres",
142
+ sandbox: false,
143
+ requires_credentials: [ "DATABASE_URL" ],
144
+ tool_hints: %w[query describe_table list_tables]
145
+ },
146
+ {
147
+ key: "activeagents",
148
+ name: "Active Agent",
149
+ description: "This dashboard's own agents, exposed as MCP tools and agent:// resources over Streamable HTTP.",
150
+ transport: "http",
151
+ # Relative to wherever the engine is mounted — the same <mount>/mcp
152
+ # route the dashboard serves.
153
+ url: "<mount>/mcp",
154
+ categories: %w[agents platform],
155
+ docs_url: "https://docs.activeagents.ai/mcp",
156
+ sandbox: false,
157
+ first_party: true,
158
+ requires_credentials: [ "Platform API key" ],
159
+ tool_hints: %w[call_agent list_agents]
160
+ }
161
+ ].freeze
162
+
163
+ # key => entry, for O(1) lookup by name.
164
+ BY_KEY = SERVERS.index_by { |server| server[:key] }.freeze
165
+
166
+ # Bare tool name => server key. Built from each entry's tool_hints, so
167
+ # adding a server to SERVERS is all it takes to teach attribution about
168
+ # its tools. First declaration wins on collision (a name served by two
169
+ # catalog entries stays with the earlier, more specific one).
170
+ TOOL_HINTS = SERVERS.each_with_object({}) do |server, map|
171
+ Array(server[:tool_hints]).each { |tool| map[tool] ||= server[:key] }
172
+ end.freeze
173
+
174
+ class << self
175
+ # Every catalog entry, as API-shaped hashes.
176
+ #
177
+ # @return [Array<Hash>]
178
+ def all
179
+ SERVERS.map { |server| present(server) }
180
+ end
181
+
182
+ # Entries that can be started inside a sandbox session.
183
+ #
184
+ # @return [Array<Hash>]
185
+ def launchable
186
+ all.select { |server| server[:sandbox] }
187
+ end
188
+
189
+ # @param key [String, Symbol]
190
+ # @return [Hash, nil] the catalog entry, or nil when unknown
191
+ def find(key)
192
+ entry = BY_KEY[key.to_s]
193
+ present(entry) if entry
194
+ end
195
+
196
+ # @param key [String, Symbol]
197
+ # @return [Boolean] whether the server can be started in a sandbox
198
+ def launchable?(key)
199
+ BY_KEY[key.to_s]&.fetch(:sandbox, false) || false
200
+ end
201
+
202
+ # The catalog server a bare (non-namespaced) tool name belongs to.
203
+ #
204
+ # Only consulted after namespace parsing fails — an explicit
205
+ # +mcp__server__tool+ name always wins over a hint, because the trace
206
+ # said so and this table is only an educated guess.
207
+ #
208
+ # @param tool_name [String, Symbol]
209
+ # @return [String, nil] the server key
210
+ def server_for_tool(tool_name)
211
+ TOOL_HINTS[tool_name.to_s]
212
+ end
213
+
214
+ # The display name for a server key, falling back to the key itself for
215
+ # servers detected in telemetry but absent from the catalog.
216
+ #
217
+ # @param key [String, Symbol]
218
+ # @return [String]
219
+ def display_name(key)
220
+ BY_KEY[key.to_s]&.fetch(:name) || key.to_s
221
+ end
222
+
223
+ private
224
+
225
+ # Drops the internal-only tool_hints and normalizes optional keys so
226
+ # every entry serializes with the same shape.
227
+ def present(server)
228
+ {
229
+ key: server[:key],
230
+ name: server[:name],
231
+ description: server[:description],
232
+ transport: server[:transport],
233
+ command: server[:command],
234
+ url: server[:url],
235
+ package: server[:package],
236
+ categories: Array(server[:categories]),
237
+ docs_url: server[:docs_url],
238
+ sandbox: server.fetch(:sandbox, false),
239
+ sandbox_type: server[:sandbox_type],
240
+ first_party: server.fetch(:first_party, false),
241
+ requires_credentials: Array(server[:requires_credentials]),
242
+ tools: Array(server[:tool_hints])
243
+ }
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Middleware for recording MCP tool calls during browser automation
5
+ #
6
+ # This middleware intercepts Playwright MCP tool calls and records them
7
+ # to a SessionRecording for playback and debugging.
8
+ #
9
+ # Usage:
10
+ # middleware = McpRecordingMiddleware.new(session_recording: recording)
11
+ #
12
+ # # Process a tool call
13
+ # result = middleware.intercept(tool_call) do
14
+ # # Execute the actual MCP tool
15
+ # mcp_client.call_tool(tool_call)
16
+ # end
17
+ #
18
+ class McpRecordingMiddleware
19
+ # Map of Playwright MCP tool names to our action types
20
+ PLAYWRIGHT_TOOLS = {
21
+ "browser_navigate" => "navigate",
22
+ "browser_click" => "click",
23
+ "browser_type" => "type",
24
+ "browser_fill_form" => "form_fill",
25
+ "browser_press_key" => "key_press",
26
+ "browser_snapshot" => "snapshot",
27
+ "browser_take_screenshot" => "snapshot",
28
+ "browser_hover" => "hover",
29
+ "browser_select_option" => "select",
30
+ "browser_file_upload" => "file_upload",
31
+ "browser_handle_dialog" => "dialog",
32
+ "browser_evaluate" => "evaluate",
33
+ "browser_wait_for" => "wait",
34
+ "browser_drag" => "drag",
35
+ "browser_scroll" => "scroll"
36
+ }.freeze
37
+
38
+ attr_reader :recording_service
39
+
40
+ def initialize(session_recording: nil, sandbox_session: nil, agent_run: nil)
41
+ @recording = session_recording
42
+ @sandbox_session = sandbox_session
43
+ @agent_run = agent_run
44
+
45
+ # Create or find recording if not provided
46
+ @recording ||= find_or_create_recording
47
+ @recording_service = SessionRecordingService.new(@recording) if @recording
48
+ end
49
+
50
+ # Intercept an MCP tool call and record it
51
+ def intercept(tool_name:, parameters:)
52
+ action_type = PLAYWRIGHT_TOOLS[tool_name]
53
+
54
+ # If not a Playwright tool, just execute and return
55
+ unless action_type
56
+ return yield if block_given?
57
+ return nil
58
+ end
59
+
60
+ # Execute the tool
61
+ result = nil
62
+ screenshot = nil
63
+ error = nil
64
+
65
+ begin
66
+ result = yield if block_given?
67
+
68
+ # Extract screenshot from result if present
69
+ screenshot = extract_screenshot(result)
70
+ rescue => e
71
+ error = e.message
72
+ raise
73
+ ensure
74
+ # Record the action
75
+ record_action(action_type, tool_name, parameters, result, screenshot, error)
76
+ end
77
+
78
+ result
79
+ end
80
+
81
+ # Convenience method for recording a navigation
82
+ def record_navigate(url:, screenshot: nil)
83
+ @recording_service&.navigate(url: url, screenshot: screenshot)
84
+ end
85
+
86
+ # Convenience method for recording a click
87
+ def record_click(selector:, screenshot: nil, element_description: nil)
88
+ @recording_service&.click(
89
+ selector: selector,
90
+ screenshot: screenshot,
91
+ metadata: { element: element_description }.compact
92
+ )
93
+ end
94
+
95
+ # Convenience method for recording text input
96
+ def record_type(selector:, text:, screenshot: nil)
97
+ @recording_service&.type(selector: selector, text: text, screenshot: screenshot)
98
+ end
99
+
100
+ # Record current page state for handoff
101
+ def capture_for_handoff(url:, cookies: nil, local_storage: nil, form_values: nil)
102
+ @recording_service&.capture_handoff_state(
103
+ url: url,
104
+ cookies: cookies,
105
+ local_storage: local_storage,
106
+ form_values: form_values
107
+ )
108
+ end
109
+
110
+ # Complete the recording
111
+ def complete!
112
+ @recording_service&.complete!
113
+ end
114
+
115
+ # Fail the recording
116
+ def fail!(error_message = nil)
117
+ @recording_service&.fail!(error_message)
118
+ end
119
+
120
+ private
121
+
122
+ def find_or_create_recording
123
+ if @sandbox_session
124
+ SessionRecording.recording.find_by(sandbox_session: @sandbox_session) ||
125
+ SessionRecording.start!(sandbox_session: @sandbox_session)
126
+ elsif @agent_run
127
+ SessionRecording.recording.find_by(agent_run: @agent_run) ||
128
+ SessionRecording.start!(agent_run: @agent_run)
129
+ end
130
+ end
131
+
132
+ def record_action(action_type, tool_name, parameters, result, screenshot, error)
133
+ return unless @recording_service
134
+
135
+ metadata = {
136
+ tool_name: tool_name,
137
+ parameters: parameters,
138
+ error: error
139
+ }.compact
140
+
141
+ case action_type
142
+ when "navigate"
143
+ @recording_service.navigate(
144
+ url: parameters["url"],
145
+ screenshot: screenshot
146
+ )
147
+ when "click"
148
+ @recording_service.click(
149
+ selector: parameters["ref"] || parameters["selector"],
150
+ screenshot: screenshot,
151
+ metadata: metadata.merge(
152
+ element: parameters["element"],
153
+ button: parameters["button"]
154
+ ).compact
155
+ )
156
+ when "type"
157
+ @recording_service.type(
158
+ selector: parameters["ref"] || parameters["selector"],
159
+ text: parameters["text"],
160
+ screenshot: screenshot,
161
+ metadata: metadata
162
+ )
163
+ when "form_fill"
164
+ @recording_service.fill_form(
165
+ fields: parameters["fields"] || [],
166
+ screenshot: screenshot
167
+ )
168
+ when "key_press"
169
+ @recording_service.key_press(
170
+ key: parameters["key"],
171
+ screenshot: screenshot
172
+ )
173
+ when "snapshot"
174
+ # For snapshots, extract DOM if available
175
+ dom = extract_dom(result)
176
+ @recording_service.capture_snapshot(
177
+ screenshot: screenshot,
178
+ dom: dom,
179
+ full_page: parameters["fullPage"]
180
+ )
181
+ when "hover"
182
+ @recording_service.hover(
183
+ selector: parameters["ref"] || parameters["selector"],
184
+ screenshot: screenshot
185
+ )
186
+ when "select"
187
+ @recording_service.select_option(
188
+ selector: parameters["ref"] || parameters["selector"],
189
+ values: parameters["values"] || [],
190
+ screenshot: screenshot
191
+ )
192
+ when "file_upload"
193
+ @recording_service.file_upload(
194
+ paths: parameters["paths"] || [],
195
+ screenshot: screenshot
196
+ )
197
+ when "dialog"
198
+ @recording_service.dialog(
199
+ accept: parameters["accept"],
200
+ text: parameters["promptText"],
201
+ screenshot: screenshot
202
+ )
203
+ when "evaluate"
204
+ @recording_service.evaluate(
205
+ function: parameters["function"],
206
+ result: result,
207
+ screenshot: screenshot
208
+ )
209
+ else
210
+ # Generic recording for other actions
211
+ @recording&.record_action!(
212
+ action_type: action_type,
213
+ selector: parameters["ref"] || parameters["selector"],
214
+ value: parameters.to_json,
215
+ screenshot: screenshot,
216
+ metadata: metadata
217
+ )
218
+ end
219
+ end
220
+
221
+ def extract_screenshot(result)
222
+ return nil unless result.is_a?(Hash) || result.respond_to?(:[])
223
+
224
+ # Try different paths where screenshot data might be
225
+ result["screenshot"] ||
226
+ result[:screenshot] ||
227
+ result.dig("data", "screenshot") ||
228
+ result.dig(:data, :screenshot)
229
+ end
230
+
231
+ def extract_dom(result)
232
+ return nil unless result.is_a?(Hash) || result.respond_to?(:[])
233
+
234
+ result["dom"] ||
235
+ result[:dom] ||
236
+ result["snapshot"] ||
237
+ result[:snapshot] ||
238
+ result.dig("data", "dom")
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # In-memory sandbox backend. Ships with the engine so the sandbox surface
5
+ # is exercisable in development and tests without any container runtime;
6
+ # backends that talk to real infrastructure are registered by the host app
7
+ # (see ActionAgent.sandbox_backends).
8
+ class MockSandboxBackend
9
+ def initialize
10
+ @sandboxes = {}
11
+ end
12
+
13
+ def create_sandbox(session, instance_tier: nil)
14
+ tier = instance_tier || SandboxInstanceTier.free_tier
15
+ name = "mock-sandbox-#{SecureRandom.hex(4)}"
16
+
17
+ @sandboxes[name] = {
18
+ container_name: name,
19
+ container_ip: "127.0.0.1",
20
+ url: "http://127.0.0.1:8080",
21
+ session_id: session.session_id,
22
+ status: "running",
23
+ instance_tier: tier.id,
24
+ resources: {
25
+ cpu_cores: tier.cpu_cores,
26
+ memory_gb: tier.memory_gb,
27
+ gpu: tier.gpu
28
+ },
29
+ hourly_cost: tier.hourly_cost.to_f,
30
+ created_at: Time.current
31
+ }
32
+ @sandboxes[name]
33
+ end
34
+
35
+ def status(sandbox_id)
36
+ @sandboxes[sandbox_id] || { status: "not_found" }
37
+ end
38
+
39
+ def terminate(sandbox_id)
40
+ @sandboxes.delete(sandbox_id)
41
+ true
42
+ end
43
+
44
+ def list_sandboxes
45
+ @sandboxes.values
46
+ end
47
+
48
+ def cleanup_expired
49
+ 0
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ require "net/http"
5
+ require "resolv"
6
+
7
+ # Minimal MCP client (streamable HTTP transport) for a Playwright MCP
8
+ # server — typically `npx @playwright/mcp --port 8931` running beside the
9
+ # app in development, or a sandbox-provisioned browser container in
10
+ # production. Speaks just enough JSON-RPC for tools/call: initialize once
11
+ # per process, then call tools under the session id the server hands back.
12
+ class PlaywrightMcpClient
13
+ DEFAULT_URL = ENV.fetch("PLAYWRIGHT_MCP_URL", "http://host.orb.internal:8931/mcp")
14
+ OPEN_TIMEOUT_SECONDS = 5
15
+ READ_TIMEOUT_SECONDS = 60
16
+
17
+ class Error < StandardError; end
18
+
19
+ def self.instance
20
+ @instance ||= new
21
+ end
22
+
23
+ def self.reset!
24
+ @instance = nil
25
+ end
26
+
27
+ def initialize(url: DEFAULT_URL)
28
+ @uri = URI(url)
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ # Returns { text:, is_error: } — the tool result's text content.
33
+ def call_tool(name, arguments = {})
34
+ Rails.logger.debug("[PlaywrightMcpClient] call #{name} args=#{arguments.inspect[0, 200]}")
35
+ ensure_session!
36
+ response = post(
37
+ { jsonrpc: "2.0", id: next_id, method: "tools/call",
38
+ params: { name: name, arguments: arguments } },
39
+ session: @session_id
40
+ )
41
+ result = response["result"]
42
+ unless result
43
+ Rails.logger.warn("[PlaywrightMcpClient] #{name} unexpected response: #{response.inspect[0, 500]}")
44
+ raise Error, (response.dig("error", "message") || "empty MCP response")
45
+ end
46
+
47
+ text = Array(result["content"]).filter_map { |block| block["text"] }.join("\n")
48
+ { text: text, is_error: result["isError"] ? true : false }
49
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, SocketError => e
50
+ self.class.reset!
51
+ raise Error, "Playwright MCP server unreachable at #{@uri} (#{e.class}): start it with `npx @playwright/mcp --port 8931`"
52
+ end
53
+
54
+ private
55
+
56
+ def ensure_session!
57
+ @mutex.synchronize do
58
+ next if @session_id
59
+
60
+ _body, response = post_raw(
61
+ { jsonrpc: "2.0", id: next_id, method: "initialize",
62
+ params: { protocolVersion: "2025-03-26", capabilities: {},
63
+ clientInfo: { name: "activeagents", version: "1.0" } } }
64
+ )
65
+ @session_id = response["mcp-session-id"]
66
+ raise Error, "MCP server did not return a session id" unless @session_id
67
+
68
+ post({ jsonrpc: "2.0", method: "notifications/initialized" }, session: @session_id)
69
+ end
70
+ end
71
+
72
+ def post(payload, session: nil)
73
+ body, _response = post_raw(payload, session: session)
74
+ body
75
+ end
76
+
77
+ def post_raw(payload, session: nil)
78
+ # Tool calls run inside the provider SDK's streaming enumerator — a
79
+ # fiber, where Net::HTTP reads of SSE bodies misbehave (headers arrive,
80
+ # body comes back empty). A dedicated thread always does real blocking
81
+ # IO outside any fiber/scheduler context.
82
+ Thread.new { blocking_post_raw(payload, session: session) }.value
83
+ end
84
+
85
+ def blocking_post_raw(payload, session: nil)
86
+ http = Net::HTTP.new(@uri.host, @uri.port)
87
+ # Container->host bridge hostnames (host.orb.internal) publish an IPv6
88
+ # address whose path doesn't reach the server; dual-stack connects then
89
+ # fail intermittently. Pin to IPv4 while keeping the Host header.
90
+ if (ipv4 = ipv4_address)
91
+ http.ipaddr = ipv4
92
+ end
93
+ http.open_timeout = OPEN_TIMEOUT_SECONDS
94
+ http.read_timeout = READ_TIMEOUT_SECONDS
95
+ request = Net::HTTP::Post.new(@uri.request_uri)
96
+ request["Content-Type"] = "application/json"
97
+ request["Accept"] = "application/json, text/event-stream"
98
+ request["Mcp-Session-Id"] = session if session
99
+ request.body = payload.to_json
100
+
101
+ response = http.request(request)
102
+ Rails.logger.debug(
103
+ "[PlaywrightMcpClient] #{payload[:method]} -> #{response.code} " \
104
+ "ct=#{response['Content-Type']} bytes=#{response.body.to_s.bytesize} session=#{session ? 'yes' : 'no'}"
105
+ )
106
+ unless response.code.to_i.between?(200, 299)
107
+ Rails.logger.warn("[PlaywrightMcpClient] HTTP #{response.code}: #{response.body.to_s[0, 300]}")
108
+ raise Error, "MCP server returned HTTP #{response.code}"
109
+ end
110
+
111
+ parsed = parse_body(response)
112
+ if parsed.empty? && payload[:id]
113
+ Rails.logger.warn("[PlaywrightMcpClient] unparsed body (#{response['Content-Type']}): #{response.body.to_s[0, 500]}")
114
+ end
115
+ [ parsed, response ]
116
+ end
117
+
118
+ # Streamable HTTP answers as plain JSON or as an SSE stream whose data:
119
+ # lines carry the JSON-RPC response — accept both.
120
+ def parse_body(response)
121
+ body = response.body.to_s
122
+ return {} if body.empty?
123
+
124
+ if response["Content-Type"].to_s.include?("text/event-stream")
125
+ body.lines
126
+ .select { |line| line.start_with?("data:") }
127
+ .filter_map { |line| JSON.parse(line.delete_prefix("data:").strip) rescue nil }
128
+ .find { |json| json["result"] || json["error"] } || {}
129
+ else
130
+ JSON.parse(body)
131
+ end
132
+ rescue JSON::ParserError
133
+ {}
134
+ end
135
+
136
+ def ipv4_address
137
+ return @ipv4_address if defined?(@ipv4_address)
138
+
139
+ @ipv4_address = Resolv.getaddresses(@uri.host).find { |address| address =~ Resolv::IPv4::Regex }
140
+ rescue Resolv::ResolvError
141
+ @ipv4_address = nil
142
+ end
143
+
144
+ def next_id
145
+ @id = (@id || 0) + 1
146
+ end
147
+ end
148
+ end