actionagent 0.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +103 -0
- data/app/assets/builds/action_agent.css +2 -0
- data/app/assets/builds/action_agent.js +163 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
- data/app/controllers/action_agent/api/agents_controller.rb +411 -0
- data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
- data/app/controllers/action_agent/api/base_controller.rb +112 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
- data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
- data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
- data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
- data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
- data/app/controllers/action_agent/api/templates_controller.rb +94 -0
- data/app/controllers/action_agent/api/tools_controller.rb +58 -0
- data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
- data/app/controllers/action_agent/api/traces_controller.rb +136 -0
- data/app/controllers/action_agent/application_controller.rb +105 -0
- data/app/controllers/action_agent/dashboard_controller.rb +104 -0
- data/app/controllers/action_agent/traces_controller.rb +121 -0
- data/app/jobs/action_agent/agent_execution_job.rb +52 -0
- data/app/jobs/action_agent/application_job.rb +12 -0
- data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
- data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
- data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
- data/app/jobs/action_agent/trace_retention_job.rb +57 -0
- data/app/models/action_agent/agent.rb +343 -0
- data/app/models/action_agent/agent_context.rb +129 -0
- data/app/models/action_agent/agent_generation.rb +48 -0
- data/app/models/action_agent/agent_memory.rb +50 -0
- data/app/models/action_agent/agent_memory_entry.rb +14 -0
- data/app/models/action_agent/agent_message.rb +43 -0
- data/app/models/action_agent/agent_run.rb +151 -0
- data/app/models/action_agent/agent_template.rb +182 -0
- data/app/models/action_agent/agent_version.rb +48 -0
- data/app/models/action_agent/api_key.rb +53 -0
- data/app/models/action_agent/application_record.rb +27 -0
- data/app/models/action_agent/evaluation.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +20 -0
- data/app/models/action_agent/model_pricing.rb +80 -0
- data/app/models/action_agent/provider_key.rb +60 -0
- data/app/models/action_agent/recording_action.rb +119 -0
- data/app/models/action_agent/recording_snapshot.rb +88 -0
- data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
- data/app/models/action_agent/sandbox_run.rb +45 -0
- data/app/models/action_agent/sandbox_session.rb +160 -0
- data/app/models/action_agent/session_recording.rb +178 -0
- data/app/models/action_agent/telemetry_trace.rb +357 -0
- data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
- data/app/models/concerns/action_agent/ownable.rb +86 -0
- data/app/models/concerns/action_agent/session_recordable.rb +91 -0
- data/app/queries/action_agent/agent_executions.rb +201 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
- data/app/serializers/action_agent/interaction_preview.rb +22 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
- data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
- data/app/services/action_agent/agent_execution_service.rb +572 -0
- data/app/services/action_agent/agent_registrar.rb +197 -0
- data/app/services/action_agent/agent_scorecard.rb +191 -0
- data/app/services/action_agent/agent_toolbox.rb +504 -0
- data/app/services/action_agent/evaluation_runner_service.rb +481 -0
- data/app/services/action_agent/mcp_catalog.rb +247 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
- data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
- data/app/services/action_agent/playwright_mcp_client.rb +148 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
- data/app/services/action_agent/session_recording_service.rb +228 -0
- data/app/services/action_agent/tool_discovery.rb +617 -0
- data/app/views/action_agent/dashboard/index.html.erb +5 -0
- data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
- data/app/views/action_agent/traces/index.html.erb +135 -0
- data/app/views/action_agent/traces/metrics.html.erb +145 -0
- data/app/views/action_agent/traces/show.html.erb +36 -0
- data/app/views/layouts/action_agent/application.html.erb +94 -0
- data/app/views/layouts/action_agent/react.html.erb +19 -0
- data/config/routes.rb +144 -0
- data/lib/action_agent/compatibility.rb +49 -0
- data/lib/action_agent/engine.rb +51 -0
- data/lib/action_agent/version.rb +5 -0
- data/lib/action_agent.rb +388 -0
- data/lib/actionagent.rb +6 -0
- data/lib/generators/action_agent/install_generator.rb +137 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
- data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
- data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
- metadata +209 -12
|
@@ -0,0 +1,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
|