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.
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 +92 -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 +334 -0
  94. data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +71 -0
  95. 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