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,504 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
require "resolv"
|
|
5
|
+
|
|
6
|
+
# Server-executable tools for platform generation runs, keyed by the tool
|
|
7
|
+
# names an Agent enables in the builder (Agent::AVAILABLE_TOOLS).
|
|
8
|
+
#
|
|
9
|
+
# AgentExecutionService exposes each supported tool to the provider as a
|
|
10
|
+
# function-calling schema and routes invocations here, so real runs produce
|
|
11
|
+
# tool-call roundtrips that show up as :tool spans in Traces, tool messages
|
|
12
|
+
# in Interactions, and tool counts in Metrics.
|
|
13
|
+
#
|
|
14
|
+
# Only tools with a safe server-side implementation are mapped; anything
|
|
15
|
+
# else an agent enables (terminal, playwright, ...) is ignored for platform
|
|
16
|
+
# execution.
|
|
17
|
+
class AgentToolbox
|
|
18
|
+
FETCH_LIMIT_BYTES = 50_000
|
|
19
|
+
FETCH_TIMEOUT_SECONDS = 5
|
|
20
|
+
|
|
21
|
+
# agent tool name => function-calling tool definitions (common format).
|
|
22
|
+
DEFINITIONS = {
|
|
23
|
+
"fetch" => [
|
|
24
|
+
{
|
|
25
|
+
name: "fetch_url",
|
|
26
|
+
description: "Fetch a public http(s) URL and return the beginning of its body as text. Use for reading web pages or APIs.",
|
|
27
|
+
parameters: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
url: { type: "string", description: "Absolute http(s) URL to fetch" }
|
|
31
|
+
},
|
|
32
|
+
required: [ "url" ]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"playwright" => [
|
|
37
|
+
{
|
|
38
|
+
name: "browse_page",
|
|
39
|
+
description: "Browse a page on the trusted documentation site (docs.activeagents.ai). Returns the page's readable text plus a links list of same-site paths with their link text. To follow (\"click\") a link, call browse_page again with its path. Start at \"/\" and navigate only via paths from the links list — do not invent paths.",
|
|
40
|
+
parameters: {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: {
|
|
43
|
+
url: { type: "string", description: "URL or path on docs.activeagents.ai" }
|
|
44
|
+
},
|
|
45
|
+
required: [ "url" ]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
# A real browser via a Playwright MCP server (PlaywrightMcpClient).
|
|
50
|
+
# Stateful: navigate changes what snapshot/click see, so these bypass
|
|
51
|
+
# the toolbox result cache.
|
|
52
|
+
"playwright_mcp" => [
|
|
53
|
+
{
|
|
54
|
+
name: "browser_navigate",
|
|
55
|
+
description: "Open a URL in the managed browser. Returns a text snapshot of the page with element refs.",
|
|
56
|
+
parameters: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: {
|
|
59
|
+
url: { type: "string", description: "Absolute http(s) URL to open" }
|
|
60
|
+
},
|
|
61
|
+
required: [ "url" ]
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "browser_snapshot",
|
|
66
|
+
description: "Accessibility snapshot of the current browser page: its readable structure, with element refs usable by browser_click.",
|
|
67
|
+
parameters: { type: "object", properties: {}, required: [] }
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "browser_click",
|
|
71
|
+
description: "Click an element from the latest snapshot by its ref (e.g. e12). Returns the updated page snapshot.",
|
|
72
|
+
parameters: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
ref: { type: "string", description: "Element ref from the snapshot" },
|
|
76
|
+
element: { type: "string", description: "Human-readable description of the element" }
|
|
77
|
+
},
|
|
78
|
+
required: [ "ref" ]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
# Subject-bound like memory: routed by AgentExecutionService (needs the
|
|
83
|
+
# calling agent's account scope), so NOT in FUNCTIONS below.
|
|
84
|
+
"agents" => [
|
|
85
|
+
{
|
|
86
|
+
name: "call_agent",
|
|
87
|
+
description: "Delegate a task to another agent in this workspace and return its reply. Use when another agent has tools, memory, or expertise this task needs.",
|
|
88
|
+
parameters: {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: {
|
|
91
|
+
slug: { type: "string", description: "The slug of the agent to call, e.g. local-qwen-assistant" },
|
|
92
|
+
message: { type: "string", description: "The task or question for that agent" }
|
|
93
|
+
},
|
|
94
|
+
required: [ "slug", "message" ]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
"search" => [
|
|
99
|
+
{
|
|
100
|
+
name: "web_search",
|
|
101
|
+
description: "Search the web (DuckDuckGo instant answers) and return a short summary with related topics.",
|
|
102
|
+
parameters: {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: {
|
|
105
|
+
query: { type: "string", description: "The search query" }
|
|
106
|
+
},
|
|
107
|
+
required: [ "query" ]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"code" => [
|
|
112
|
+
{
|
|
113
|
+
name: "calculate",
|
|
114
|
+
description: "Evaluate an arithmetic expression (+, -, *, /, %, parentheses) and return the numeric result.",
|
|
115
|
+
parameters: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
expression: { type: "string", description: "Arithmetic expression, e.g. (2 + 3) * 4.5" }
|
|
119
|
+
},
|
|
120
|
+
required: [ "expression" ]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
# Memory tools mirror solid_agent's HasMemory contract. They are NOT in
|
|
125
|
+
# FUNCTIONS below — execution is subject-bound, so AgentExecutionService
|
|
126
|
+
# routes them to the run's AgentMemory instead of this module.
|
|
127
|
+
"memory" => [
|
|
128
|
+
{
|
|
129
|
+
name: "save_memory",
|
|
130
|
+
description: "Persist a short summary note to long-term memory. Use for facts, decisions, task outcomes, or anything a future agent or session should know. Keep each note self-contained.",
|
|
131
|
+
parameters: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: {
|
|
134
|
+
content: { type: "string", description: "The summary note to remember" },
|
|
135
|
+
category: { type: "string", description: "Optional label, e.g. fact, task, handoff" }
|
|
136
|
+
},
|
|
137
|
+
required: [ "content" ]
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "recall_memory",
|
|
142
|
+
description: "Read back previously saved memory notes for the current subject, most recent first. Use before starting work to pick up prior context or another agent's handoff.",
|
|
143
|
+
parameters: {
|
|
144
|
+
type: "object",
|
|
145
|
+
properties: {
|
|
146
|
+
category: { type: "string", description: "Only return notes with this label" },
|
|
147
|
+
limit: { type: "integer", description: "Maximum notes to return (default 20)" }
|
|
148
|
+
},
|
|
149
|
+
required: []
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
}.freeze
|
|
154
|
+
|
|
155
|
+
# Function name => implementation method, for routing tool calls.
|
|
156
|
+
FUNCTIONS = {
|
|
157
|
+
"fetch_url" => :fetch_url,
|
|
158
|
+
"web_search" => :web_search,
|
|
159
|
+
"calculate" => :calculate,
|
|
160
|
+
"browse_page" => :browse_page,
|
|
161
|
+
"browser_navigate" => :browser_navigate,
|
|
162
|
+
"browser_snapshot" => :browser_snapshot,
|
|
163
|
+
"browser_click" => :browser_click
|
|
164
|
+
}.freeze
|
|
165
|
+
|
|
166
|
+
# Stateful tools whose results must never be replayed from cache.
|
|
167
|
+
UNCACHED_FUNCTIONS = %w[browser_navigate browser_snapshot browser_click].freeze
|
|
168
|
+
|
|
169
|
+
# Hosts browse_page may fetch — the platform's own trusted docs.
|
|
170
|
+
BROWSE_ALLOWED_HOSTS = %w[docs.activeagents.ai].freeze
|
|
171
|
+
|
|
172
|
+
class << self
|
|
173
|
+
# Tool definitions for the subset of an agent's enabled tools that have
|
|
174
|
+
# server-side implementations.
|
|
175
|
+
def definitions_for(tool_names)
|
|
176
|
+
Array(tool_names).flat_map { |name| DEFINITIONS[name.to_s] || [] }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def function?(name)
|
|
180
|
+
FUNCTIONS.key?(name.to_s)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Executes a tool call. Returns a result hash; errors are returned as
|
|
184
|
+
# { error: ... } so the model can react instead of the run crashing.
|
|
185
|
+
#
|
|
186
|
+
# Results are cached by (tool, args) with a short TTL — repeated
|
|
187
|
+
# interactions replay the persisted result (tagged cached: true)
|
|
188
|
+
# instead of re-running the side effect.
|
|
189
|
+
def call(name, **kwargs)
|
|
190
|
+
return { error: "Unknown tool: #{name}" } unless function?(name)
|
|
191
|
+
return public_send(FUNCTIONS.fetch(name.to_s), **kwargs) if UNCACHED_FUNCTIONS.include?(name.to_s)
|
|
192
|
+
|
|
193
|
+
cached_fetch(name, kwargs) do
|
|
194
|
+
public_send(FUNCTIONS.fetch(name.to_s), **kwargs)
|
|
195
|
+
end
|
|
196
|
+
rescue ArgumentError => e
|
|
197
|
+
{ error: "Invalid arguments for #{name}: #{e.message}" }
|
|
198
|
+
rescue StandardError => e
|
|
199
|
+
Rails.logger.warn("[AgentToolbox] #{name} failed: #{e.class} - #{e.message}")
|
|
200
|
+
{ error: "#{name} failed: #{e.message}" }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
MAX_REDIRECTS = 3
|
|
204
|
+
|
|
205
|
+
def fetch_url(url:)
|
|
206
|
+
uri = URI.parse(url.to_s)
|
|
207
|
+
return { error: "Only http(s) URLs are supported" } unless uri.is_a?(URI::HTTP)
|
|
208
|
+
return { error: "URL host is not allowed" } unless public_host?(uri.host)
|
|
209
|
+
|
|
210
|
+
response = nil
|
|
211
|
+
(MAX_REDIRECTS + 1).times do
|
|
212
|
+
response = Net::HTTP.start(
|
|
213
|
+
uri.host, uri.port,
|
|
214
|
+
use_ssl: uri.scheme == "https",
|
|
215
|
+
open_timeout: FETCH_TIMEOUT_SECONDS,
|
|
216
|
+
read_timeout: FETCH_TIMEOUT_SECONDS
|
|
217
|
+
) { |http| http.get(uri.request_uri.presence || "/", { "User-Agent" => "ActiveAgents-Toolbox/1.0" }) }
|
|
218
|
+
|
|
219
|
+
break unless response.is_a?(Net::HTTPRedirection) && response["Location"].present?
|
|
220
|
+
|
|
221
|
+
uri = URI.join(uri, response["Location"])
|
|
222
|
+
return { error: "Only http(s) URLs are supported" } unless uri.is_a?(URI::HTTP)
|
|
223
|
+
return { error: "Redirected to a disallowed host" } unless public_host?(uri.host)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
body = response.body.to_s.byteslice(0, FETCH_LIMIT_BYTES).to_s.scrub
|
|
227
|
+
{
|
|
228
|
+
url: uri.to_s,
|
|
229
|
+
status: response.code.to_i,
|
|
230
|
+
content_type: response["Content-Type"],
|
|
231
|
+
body: body,
|
|
232
|
+
truncated: response.body.to_s.bytesize > FETCH_LIMIT_BYTES
|
|
233
|
+
}
|
|
234
|
+
rescue URI::InvalidURIError
|
|
235
|
+
{ error: "Invalid URL" }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def web_search(query:)
|
|
239
|
+
uri = URI("https://api.duckduckgo.com/?#{URI.encode_www_form(q: query.to_s, format: "json", no_html: 1, skip_disambig: 1)}")
|
|
240
|
+
response = Net::HTTP.start(
|
|
241
|
+
uri.host, uri.port,
|
|
242
|
+
use_ssl: true, open_timeout: FETCH_TIMEOUT_SECONDS, read_timeout: FETCH_TIMEOUT_SECONDS
|
|
243
|
+
) { |http| http.get(uri.request_uri) }
|
|
244
|
+
data = JSON.parse(response.body)
|
|
245
|
+
|
|
246
|
+
{
|
|
247
|
+
query: query,
|
|
248
|
+
abstract: data["AbstractText"].presence,
|
|
249
|
+
answer: data["Answer"].presence,
|
|
250
|
+
heading: data["Heading"].presence,
|
|
251
|
+
related_topics: Array(data["RelatedTopics"]).first(5).filter_map { |topic| topic["Text"] }
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def calculate(expression:)
|
|
256
|
+
{ expression: expression, result: Calculator.evaluate(expression.to_s) }
|
|
257
|
+
rescue Calculator::Error => e
|
|
258
|
+
{ error: e.message }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Trusted-docs browser: fetch_url restricted to BROWSE_ALLOWED_HOSTS,
|
|
262
|
+
# with HTML reduced to readable text so small models aren't drowned in
|
|
263
|
+
# markup. Accepts bare paths ("/docs/agents") against the docs host.
|
|
264
|
+
# Resolves a bare path ("/docs/agents") to an absolute URL on the
|
|
265
|
+
# trusted docs host, so the fetched URL is unambiguous everywhere it is
|
|
266
|
+
# recorded (spans, run events, persisted tool arguments).
|
|
267
|
+
def resolve_browse_url(url)
|
|
268
|
+
url = url.to_s
|
|
269
|
+
return url if url.match?(%r{\Ahttps?://})
|
|
270
|
+
|
|
271
|
+
"https://#{BROWSE_ALLOWED_HOSTS.first}#{url.start_with?('/') ? url : "/#{url}"}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
PLAYWRIGHT_RESULT_LIMIT = 8_000
|
|
275
|
+
|
|
276
|
+
def browser_navigate(url:)
|
|
277
|
+
playwright_mcp("browser_navigate", { url: url })
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def browser_snapshot
|
|
281
|
+
playwright_mcp("browser_snapshot", {})
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def browser_click(ref:, element: nil)
|
|
285
|
+
playwright_mcp("browser_click", { ref: ref, element: element || ref })
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
SNAPSHOT_LINK = /\[Snapshot\]\(([^)]+)\)/
|
|
289
|
+
|
|
290
|
+
def playwright_mcp(tool, arguments, retried: false)
|
|
291
|
+
result = PlaywrightMcpClient.instance.call_tool(tool, arguments)
|
|
292
|
+
text = inline_snapshot(result[:text].to_s)
|
|
293
|
+
if text.length > PLAYWRIGHT_RESULT_LIMIT
|
|
294
|
+
text = "#{text[0, PLAYWRIGHT_RESULT_LIMIT]}\n…(truncated, #{text.length} chars total)"
|
|
295
|
+
end
|
|
296
|
+
result[:is_error] ? { error: text.presence || "browser tool failed" } : { text: text }
|
|
297
|
+
rescue PlaywrightMcpClient::Error => e
|
|
298
|
+
# One fresh-session retry: the first call after a server (re)start can
|
|
299
|
+
# race the browser launch.
|
|
300
|
+
unless retried
|
|
301
|
+
PlaywrightMcpClient.reset!
|
|
302
|
+
return playwright_mcp(tool, arguments, retried: true)
|
|
303
|
+
end
|
|
304
|
+
{ error: e.message }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The MCP server saves page snapshots to files; when it runs beside the
|
|
308
|
+
# app with its output dir on a shared path, read them back so the model
|
|
309
|
+
# sees the page inline.
|
|
310
|
+
def inline_snapshot(text)
|
|
311
|
+
match = SNAPSHOT_LINK.match(text)
|
|
312
|
+
return text unless match
|
|
313
|
+
|
|
314
|
+
file = Rails.root.join(".playwright-mcp", File.basename(match[1]))
|
|
315
|
+
return text unless File.exist?(file)
|
|
316
|
+
|
|
317
|
+
"#{text}\n\n### Page snapshot\n#{File.read(file)}"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def browse_page(url:)
|
|
321
|
+
url = resolve_browse_url(url)
|
|
322
|
+
host = URI.parse(url.to_s).host
|
|
323
|
+
unless BROWSE_ALLOWED_HOSTS.include?(host)
|
|
324
|
+
return { error: "browse_page is limited to trusted hosts: #{BROWSE_ALLOWED_HOSTS.join(', ')}" }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
result = fetch_url(url: url)
|
|
328
|
+
return result if result[:error]
|
|
329
|
+
|
|
330
|
+
# A redirect may not stay on the trusted host — re-check after fetch.
|
|
331
|
+
final_host = URI.parse(result[:url].to_s).host
|
|
332
|
+
unless BROWSE_ALLOWED_HOSTS.include?(final_host)
|
|
333
|
+
return { error: "Page redirected off the trusted docs host (#{final_host})" }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
if result[:status] == 404
|
|
337
|
+
return {
|
|
338
|
+
url: result[:url], status: 404,
|
|
339
|
+
error: "Page not found. Don't guess paths — browse '/' and follow the paths in the result's links list."
|
|
340
|
+
}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
links = extract_links(result[:body].to_s, result[:url])
|
|
344
|
+
|
|
345
|
+
text = result[:body].to_s
|
|
346
|
+
.gsub(%r{<(script|style)[^>]*>.*?</\1>}mi, " ")
|
|
347
|
+
.gsub(/<[^>]+>/, " ")
|
|
348
|
+
.then { |stripped| CGI.unescapeHTML(stripped) }
|
|
349
|
+
.gsub(/\s*\n\s*/, "\n")
|
|
350
|
+
.gsub(/[ \t]+/, " ")
|
|
351
|
+
.gsub(/\n{2,}/, "\n")
|
|
352
|
+
.strip
|
|
353
|
+
|
|
354
|
+
{
|
|
355
|
+
url: result[:url], status: result[:status],
|
|
356
|
+
text: text.byteslice(0, 20_000).to_s.scrub,
|
|
357
|
+
links: links,
|
|
358
|
+
truncated: result[:truncated] || text.bytesize > 20_000
|
|
359
|
+
}
|
|
360
|
+
rescue URI::InvalidURIError
|
|
361
|
+
{ error: "Invalid URL" }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
BROWSE_LINK_LIMIT = 40
|
|
365
|
+
|
|
366
|
+
# Same-site links from the page HTML, so the model can navigate by
|
|
367
|
+
# following real paths instead of guessing them. "Clicking" a link is
|
|
368
|
+
# calling browse_page with its path.
|
|
369
|
+
def extract_links(html, base_url)
|
|
370
|
+
base = URI.parse(base_url.to_s)
|
|
371
|
+
links = html.scan(%r{<a\s[^>]*href=["']([^"'\s]+)["'][^>]*>(.*?)</a>}mi).filter_map do |href, inner|
|
|
372
|
+
next if href.start_with?("javascript:", "mailto:", "tel:", "data:", "#")
|
|
373
|
+
|
|
374
|
+
resolved = begin
|
|
375
|
+
URI.join(base, href)
|
|
376
|
+
rescue URI::Error
|
|
377
|
+
next
|
|
378
|
+
end
|
|
379
|
+
next unless resolved.is_a?(URI::HTTP) && BROWSE_ALLOWED_HOSTS.include?(resolved.host)
|
|
380
|
+
|
|
381
|
+
text = CGI.unescapeHTML(inner.gsub(/<[^>]+>/, " ")).gsub(/\s+/, " ").strip
|
|
382
|
+
entry = { path: resolved.request_uri }
|
|
383
|
+
entry[:text] = text.byteslice(0, 80).to_s.scrub if text.present?
|
|
384
|
+
entry
|
|
385
|
+
end
|
|
386
|
+
links.uniq { |link| link[:path] }.first(BROWSE_LINK_LIMIT)
|
|
387
|
+
rescue URI::Error
|
|
388
|
+
[]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
private
|
|
392
|
+
|
|
393
|
+
CACHE_TTL = 5.minutes
|
|
394
|
+
|
|
395
|
+
# Uses SolidAgent::ToolCache when the installed solid_agent provides it
|
|
396
|
+
# (it carries the canonical key scheme + error-skipping semantics);
|
|
397
|
+
# falls back to an equivalent Rails.cache fetch on older gem versions.
|
|
398
|
+
def cached_fetch(name, kwargs, &block)
|
|
399
|
+
if defined?(SolidAgent::ToolCache)
|
|
400
|
+
SolidAgent::ToolCache.fetch(tool: name.to_s, args: kwargs, ttl: CACHE_TTL, &block)
|
|
401
|
+
else
|
|
402
|
+
key = "solid_agent:tool_cache:#{name}:#{Digest::SHA256.hexdigest(kwargs.sort.to_h.to_json)}"
|
|
403
|
+
cached = Rails.cache.read(key)
|
|
404
|
+
return cached.merge(cached: true) unless cached.nil?
|
|
405
|
+
|
|
406
|
+
result = block.call
|
|
407
|
+
unless result.respond_to?(:key?) && (result.key?(:error) || result.key?("error"))
|
|
408
|
+
Rails.cache.write(key, result, expires_in: CACHE_TTL)
|
|
409
|
+
end
|
|
410
|
+
result
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# SSRF guard for fetch_url: reject hosts that resolve to loopback,
|
|
415
|
+
# private, or link-local addresses.
|
|
416
|
+
def public_host?(host)
|
|
417
|
+
return false if host.blank?
|
|
418
|
+
|
|
419
|
+
addresses = Resolv.getaddresses(host)
|
|
420
|
+
return false if addresses.empty?
|
|
421
|
+
|
|
422
|
+
addresses.all? do |address|
|
|
423
|
+
ip = IPAddr.new(address)
|
|
424
|
+
!(ip.loopback? || ip.private? || ip.link_local?)
|
|
425
|
+
end
|
|
426
|
+
rescue IPAddr::InvalidAddressError, Resolv::ResolvError
|
|
427
|
+
false
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Minimal recursive-descent arithmetic evaluator (no eval).
|
|
432
|
+
# Grammar: expr := term (('+'|'-') term)*; term := factor (('*'|'/'|'%') factor)*;
|
|
433
|
+
# factor := '-'? (number | '(' expr ')')
|
|
434
|
+
module Calculator
|
|
435
|
+
class Error < StandardError; end
|
|
436
|
+
|
|
437
|
+
module_function
|
|
438
|
+
|
|
439
|
+
def evaluate(expression)
|
|
440
|
+
tokens = tokenize(expression)
|
|
441
|
+
result, rest = parse_expr(tokens)
|
|
442
|
+
raise Error, "Unexpected input: #{rest.join(' ')}" unless rest.empty?
|
|
443
|
+
|
|
444
|
+
result = result.to_f
|
|
445
|
+
result % 1 == 0 ? result.to_i : result.round(10)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def tokenize(expression)
|
|
449
|
+
tokens = expression.scan(%r{\d+(?:\.\d+)?|[-+*/%()]|\S})
|
|
450
|
+
invalid = tokens.grep_v(%r{\A(?:\d+(?:\.\d+)?|[-+*/%()])\z})
|
|
451
|
+
raise Error, "Unsupported characters: #{invalid.uniq.join(' ')}" if invalid.any?
|
|
452
|
+
raise Error, "Empty expression" if tokens.empty?
|
|
453
|
+
|
|
454
|
+
tokens
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def parse_expr(tokens)
|
|
458
|
+
value, tokens = parse_term(tokens)
|
|
459
|
+
while tokens.first == "+" || tokens.first == "-"
|
|
460
|
+
op = tokens.shift
|
|
461
|
+
rhs, tokens = parse_term(tokens)
|
|
462
|
+
value = op == "+" ? value + rhs : value - rhs
|
|
463
|
+
end
|
|
464
|
+
[ value, tokens ]
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def parse_term(tokens)
|
|
468
|
+
value, tokens = parse_factor(tokens)
|
|
469
|
+
while [ "*", "/", "%" ].include?(tokens.first)
|
|
470
|
+
op = tokens.shift
|
|
471
|
+
rhs, tokens = parse_factor(tokens)
|
|
472
|
+
raise Error, "Division by zero" if rhs.zero? && op != "*"
|
|
473
|
+
value = case op
|
|
474
|
+
when "*" then value * rhs
|
|
475
|
+
when "/" then value / rhs
|
|
476
|
+
else value % rhs
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
[ value, tokens ]
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def parse_factor(tokens)
|
|
483
|
+
raise Error, "Unexpected end of expression" if tokens.empty?
|
|
484
|
+
|
|
485
|
+
if tokens.first == "-"
|
|
486
|
+
tokens.shift
|
|
487
|
+
value, tokens = parse_factor(tokens)
|
|
488
|
+
return [ -value, tokens ]
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
token = tokens.shift
|
|
492
|
+
if token == "("
|
|
493
|
+
value, tokens = parse_expr(tokens)
|
|
494
|
+
raise Error, "Missing closing parenthesis" unless tokens.shift == ")"
|
|
495
|
+
[ value, tokens ]
|
|
496
|
+
elsif token =~ /\A\d/
|
|
497
|
+
[ token.include?(".") ? token.to_f : Rational(token), tokens ]
|
|
498
|
+
else
|
|
499
|
+
raise Error, "Unexpected token: #{token}"
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|