Class: ActionAgent::AgentToolbox

Inherits:
Object
  • Object
show all
Defined in:
app/services/action_agent/agent_toolbox.rb

Overview

Server-executable tools for platform generation runs, keyed by the tool names an Agent enables in the builder (Agent::AVAILABLE_TOOLS).

AgentExecutionService exposes each supported tool to the provider as a function-calling schema and routes invocations here, so real runs produce tool-call roundtrips that show up as :tool spans in Traces, tool messages in Interactions, and tool counts in Metrics.

Only tools with a safe server-side implementation are mapped; anything else an agent enables (terminal, playwright, ...) is ignored for platform execution.

Defined Under Namespace

Modules: Calculator

Constant Summary collapse

FETCH_LIMIT_BYTES =
50_000
FETCH_TIMEOUT_SECONDS =
5
DEFINITIONS =

agent tool name => function-calling tool definitions (common format).

{
  "fetch" => [
    {
      name: "fetch_url",
      description: "Fetch a public http(s) URL and return the beginning of its body as text. Use for reading web pages or APIs.",
      parameters: {
        type: "object",
        properties: {
          url: { type: "string", description: "Absolute http(s) URL to fetch" }
        },
        required: [ "url" ]
      }
    }
  ],
  "playwright" => [
    {
      name: "browse_page",
      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.",
      parameters: {
        type: "object",
        properties: {
          url: { type: "string", description: "URL or path on docs.activeagents.ai" }
        },
        required: [ "url" ]
      }
    }
  ],
  # A real browser via a Playwright MCP server (PlaywrightMcpClient).
  # Stateful: navigate changes what snapshot/click see, so these bypass
  # the toolbox result cache.
  "playwright_mcp" => [
    {
      name: "browser_navigate",
      description: "Open a URL in the managed browser. Returns a text snapshot of the page with element refs.",
      parameters: {
        type: "object",
        properties: {
          url: { type: "string", description: "Absolute http(s) URL to open" }
        },
        required: [ "url" ]
      }
    },
    {
      name: "browser_snapshot",
      description: "Accessibility snapshot of the current browser page: its readable structure, with element refs usable by browser_click.",
      parameters: { type: "object", properties: {}, required: [] }
    },
    {
      name: "browser_click",
      description: "Click an element from the latest snapshot by its ref (e.g. e12). Returns the updated page snapshot.",
      parameters: {
        type: "object",
        properties: {
          ref: { type: "string", description: "Element ref from the snapshot" },
          element: { type: "string", description: "Human-readable description of the element" }
        },
        required: [ "ref" ]
      }
    }
  ],
  # Subject-bound like memory: routed by AgentExecutionService (needs the
  # calling agent's account scope), so NOT in FUNCTIONS below.
  "agents" => [
    {
      name: "call_agent",
      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.",
      parameters: {
        type: "object",
        properties: {
          slug: { type: "string", description: "The slug of the agent to call, e.g. local-qwen-assistant" },
          message: { type: "string", description: "The task or question for that agent" }
        },
        required: [ "slug", "message" ]
      }
    }
  ],
  "search" => [
    {
      name: "web_search",
      description: "Search the web (DuckDuckGo instant answers) and return a short summary with related topics.",
      parameters: {
        type: "object",
        properties: {
          query: { type: "string", description: "The search query" }
        },
        required: [ "query" ]
      }
    }
  ],
  "code" => [
    {
      name: "calculate",
      description: "Evaluate an arithmetic expression (+, -, *, /, %, parentheses) and return the numeric result.",
      parameters: {
        type: "object",
        properties: {
          expression: { type: "string", description: "Arithmetic expression, e.g. (2 + 3) * 4.5" }
        },
        required: [ "expression" ]
      }
    }
  ],
  # Memory tools mirror solid_agent's HasMemory contract. They are NOT in
  # FUNCTIONS below — execution is subject-bound, so AgentExecutionService
  # routes them to the run's AgentMemory instead of this module.
  "memory" => [
    {
      name: "save_memory",
      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.",
      parameters: {
        type: "object",
        properties: {
          content: { type: "string", description: "The summary note to remember" },
          category: { type: "string", description: "Optional label, e.g. fact, task, handoff" }
        },
        required: [ "content" ]
      }
    },
    {
      name: "recall_memory",
      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.",
      parameters: {
        type: "object",
        properties: {
          category: { type: "string", description: "Only return notes with this label" },
          limit: { type: "integer", description: "Maximum notes to return (default 20)" }
        },
        required: []
      }
    }
  ]
}.freeze
FUNCTIONS =

Function name => implementation method, for routing tool calls.

{
  "fetch_url" => :fetch_url,
  "web_search" => :web_search,
  "calculate" => :calculate,
  "browse_page" => :browse_page,
  "browser_navigate" => :browser_navigate,
  "browser_snapshot" => :browser_snapshot,
  "browser_click" => :browser_click
}.freeze
UNCACHED_FUNCTIONS =

Stateful tools whose results must never be replayed from cache.

%w[browser_navigate browser_snapshot browser_click].freeze
BROWSE_ALLOWED_HOSTS =

Hosts browse_page may fetch — the platform's own trusted docs.

%w[docs.activeagents.ai].freeze
MAX_REDIRECTS =
3
PLAYWRIGHT_RESULT_LIMIT =
8_000
/\[Snapshot\]\(([^)]+)\)/
40

Class Method Summary collapse

Class Method Details

.browse_page(url:) ⇒ Object



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'app/services/action_agent/agent_toolbox.rb', line 320

def browse_page(url:)
  url = resolve_browse_url(url)
  host = URI.parse(url.to_s).host
  unless BROWSE_ALLOWED_HOSTS.include?(host)
    return { error: "browse_page is limited to trusted hosts: #{BROWSE_ALLOWED_HOSTS.join(', ')}" }
  end

  result = fetch_url(url: url)
  return result if result[:error]

  # A redirect may not stay on the trusted host — re-check after fetch.
  final_host = URI.parse(result[:url].to_s).host
  unless BROWSE_ALLOWED_HOSTS.include?(final_host)
    return { error: "Page redirected off the trusted docs host (#{final_host})" }
  end

  if result[:status] == 404
    return {
      url: result[:url], status: 404,
      error: "Page not found. Don't guess paths — browse '/' and follow the paths in the result's links list."
    }
  end

  links = extract_links(result[:body].to_s, result[:url])

  text = result[:body].to_s
    .gsub(%r{<(script|style)[^>]*>.*?</\1>}mi, " ")
    .gsub(/<[^>]+>/, " ")
    .then { |stripped| CGI.unescapeHTML(stripped) }
    .gsub(/\s*\n\s*/, "\n")
    .gsub(/[ \t]+/, " ")
    .gsub(/\n{2,}/, "\n")
    .strip

  {
    url: result[:url], status: result[:status],
    text: text.byteslice(0, 20_000).to_s.scrub,
    links: links,
    truncated: result[:truncated] || text.bytesize > 20_000
  }
rescue URI::InvalidURIError
  { error: "Invalid URL" }
end

.browser_click(ref:, element: nil) ⇒ Object



284
285
286
# File 'app/services/action_agent/agent_toolbox.rb', line 284

def browser_click(ref:, element: nil)
  playwright_mcp("browser_click", { ref: ref, element: element || ref })
end

.browser_navigate(url:) ⇒ Object



276
277
278
# File 'app/services/action_agent/agent_toolbox.rb', line 276

def browser_navigate(url:)
  playwright_mcp("browser_navigate", { url: url })
end

.browser_snapshotObject



280
281
282
# File 'app/services/action_agent/agent_toolbox.rb', line 280

def browser_snapshot
  playwright_mcp("browser_snapshot", {})
end

.calculate(expression:) ⇒ Object



255
256
257
258
259
# File 'app/services/action_agent/agent_toolbox.rb', line 255

def calculate(expression:)
  { expression: expression, result: Calculator.evaluate(expression.to_s) }
rescue Calculator::Error => e
  { error: e.message }
end

.call(name, **kwargs) ⇒ Object

Executes a tool call. Returns a result hash; errors are returned as { error: ... } so the model can react instead of the run crashing.

Results are cached by (tool, args) with a short TTL — repeated interactions replay the persisted result (tagged cached: true) instead of re-running the side effect.



189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'app/services/action_agent/agent_toolbox.rb', line 189

def call(name, **kwargs)
  return { error: "Unknown tool: #{name}" } unless function?(name)
  return public_send(FUNCTIONS.fetch(name.to_s), **kwargs) if UNCACHED_FUNCTIONS.include?(name.to_s)

  cached_fetch(name, kwargs) do
    public_send(FUNCTIONS.fetch(name.to_s), **kwargs)
  end
rescue ArgumentError => e
  { error: "Invalid arguments for #{name}: #{e.message}" }
rescue StandardError => e
  Rails.logger.warn("[AgentToolbox] #{name} failed: #{e.class} - #{e.message}")
  { error: "#{name} failed: #{e.message}" }
end

.definitions_for(tool_names) ⇒ Object

Tool definitions for the subset of an agent's enabled tools that have server-side implementations.



175
176
177
# File 'app/services/action_agent/agent_toolbox.rb', line 175

def definitions_for(tool_names)
  Array(tool_names).flat_map { |name| DEFINITIONS[name.to_s] || [] }
end

Same-site links from the page HTML, so the model can navigate by following real paths instead of guessing them. "Clicking" a link is calling browse_page with its path.



369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'app/services/action_agent/agent_toolbox.rb', line 369

def extract_links(html, base_url)
  base = URI.parse(base_url.to_s)
  links = html.scan(%r{<a\s[^>]*href=["']([^"'\s]+)["'][^>]*>(.*?)</a>}mi).filter_map do |href, inner|
    next if href.start_with?("javascript:", "mailto:", "tel:", "data:", "#")

    resolved = begin
      URI.join(base, href)
    rescue URI::Error
      next
    end
    next unless resolved.is_a?(URI::HTTP) && BROWSE_ALLOWED_HOSTS.include?(resolved.host)

    text = CGI.unescapeHTML(inner.gsub(/<[^>]+>/, " ")).gsub(/\s+/, " ").strip
    entry = { path: resolved.request_uri }
    entry[:text] = text.byteslice(0, 80).to_s.scrub if text.present?
    entry
  end
  links.uniq { |link| link[:path] }.first(BROWSE_LINK_LIMIT)
rescue URI::Error
  []
end

.fetch_url(url:) ⇒ Object



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'app/services/action_agent/agent_toolbox.rb', line 205

def fetch_url(url:)
  uri = URI.parse(url.to_s)
  return { error: "Only http(s) URLs are supported" } unless uri.is_a?(URI::HTTP)
  return { error: "URL host is not allowed" } unless public_host?(uri.host)

  response = nil
  (MAX_REDIRECTS + 1).times do
    response = Net::HTTP.start(
      uri.host, uri.port,
      use_ssl: uri.scheme == "https",
      open_timeout: FETCH_TIMEOUT_SECONDS,
      read_timeout: FETCH_TIMEOUT_SECONDS
    ) { |http| http.get(uri.request_uri.presence || "/", { "User-Agent" => "ActiveAgents-Toolbox/1.0" }) }

    break unless response.is_a?(Net::HTTPRedirection) && response["Location"].present?

    uri = URI.join(uri, response["Location"])
    return { error: "Only http(s) URLs are supported" } unless uri.is_a?(URI::HTTP)
    return { error: "Redirected to a disallowed host" } unless public_host?(uri.host)
  end

  body = response.body.to_s.byteslice(0, FETCH_LIMIT_BYTES).to_s.scrub
  {
    url: uri.to_s,
    status: response.code.to_i,
    content_type: response["Content-Type"],
    body: body,
    truncated: response.body.to_s.bytesize > FETCH_LIMIT_BYTES
  }
rescue URI::InvalidURIError
  { error: "Invalid URL" }
end

.function?(name) ⇒ Boolean

Returns:

  • (Boolean)


179
180
181
# File 'app/services/action_agent/agent_toolbox.rb', line 179

def function?(name)
  FUNCTIONS.key?(name.to_s)
end

.inline_snapshot(text) ⇒ Object

The MCP server saves page snapshots to files; when it runs beside the app with its output dir on a shared path, read them back so the model sees the page inline.



310
311
312
313
314
315
316
317
318
# File 'app/services/action_agent/agent_toolbox.rb', line 310

def inline_snapshot(text)
  match = SNAPSHOT_LINK.match(text)
  return text unless match

  file = Rails.root.join(".playwright-mcp", File.basename(match[1]))
  return text unless File.exist?(file)

  "#{text}\n\n### Page snapshot\n#{File.read(file)}"
end

.playwright_mcp(tool, arguments, retried: false) ⇒ Object



290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
# File 'app/services/action_agent/agent_toolbox.rb', line 290

def playwright_mcp(tool, arguments, retried: false)
  result = PlaywrightMcpClient.instance.call_tool(tool, arguments)
  text = inline_snapshot(result[:text].to_s)
  if text.length > PLAYWRIGHT_RESULT_LIMIT
    text = "#{text[0, PLAYWRIGHT_RESULT_LIMIT]}\n…(truncated, #{text.length} chars total)"
  end
  result[:is_error] ? { error: text.presence || "browser tool failed" } : { text: text }
rescue PlaywrightMcpClient::Error => e
  # One fresh-session retry: the first call after a server (re)start can
  # race the browser launch.
  unless retried
    PlaywrightMcpClient.reset!
    return playwright_mcp(tool, arguments, retried: true)
  end
  { error: e.message }
end

.resolve_browse_url(url) ⇒ Object

Trusted-docs browser: fetch_url restricted to BROWSE_ALLOWED_HOSTS, with HTML reduced to readable text so small models aren't drowned in markup. Accepts bare paths ("/docs/agents") against the docs host. Resolves a bare path ("/docs/agents") to an absolute URL on the trusted docs host, so the fetched URL is unambiguous everywhere it is recorded (spans, run events, persisted tool arguments).



267
268
269
270
271
272
# File 'app/services/action_agent/agent_toolbox.rb', line 267

def resolve_browse_url(url)
  url = url.to_s
  return url if url.match?(%r{\Ahttps?://})

  "https://#{BROWSE_ALLOWED_HOSTS.first}#{url.start_with?('/') ? url : "/#{url}"}"
end

.web_search(query:) ⇒ Object



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'app/services/action_agent/agent_toolbox.rb', line 238

def web_search(query:)
  uri = URI("https://api.duckduckgo.com/?#{URI.encode_www_form(q: query.to_s, format: "json", no_html: 1, skip_disambig: 1)}")
  response = Net::HTTP.start(
    uri.host, uri.port,
    use_ssl: true, open_timeout: FETCH_TIMEOUT_SECONDS, read_timeout: FETCH_TIMEOUT_SECONDS
  ) { |http| http.get(uri.request_uri) }
  data = JSON.parse(response.body)

  {
    query: query,
    abstract: data["AbstractText"].presence,
    answer: data["Answer"].presence,
    heading: data["Heading"].presence,
    related_topics: Array(data["RelatedTopics"]).first(5).filter_map { |topic| topic["Text"] }
  }
end