Class: Assistant::ChatToolBuilder

Inherits:
Object
  • Object
show all
Defined in:
app/services/assistant/chat_tool_builder.rb

Overview

Builds RubyLLM::Tool subclasses from the same logic the MCP server tools use.
This allows the assistant chat to call tools natively without an HTTP hop to the MCP server.

Supports two categories of tools:

  • Content tools: Wrap ApplicationTool descendants (semantic_search, find_products, etc.)
  • Other services: Delegated to focused builders (PostgresChatToolBuilder, AnalyticsToolBuilder, etc.)

Usage:
tools = Assistant::ChatToolBuilder.tools_for_services(['content', 'app_db'])
chat.with_tools(*tools)

Central catalog of handles, labels, and routing — splitting would scatter
the service → builder mapping this class exists to hold.
rubocop:disable Metrics/ClassLength

Constant Summary collapse

MAX_TOOL_RESULT_CHARS =

Maximum characters for any single tool result (SQL, API, content searches).
Prevents a single tool call from consuming too much of the context window.
~15K chars ≈ ~4K tokens. Most results are useful in the first few thousand
chars; the tail is pagination noise. get_blog_post has its own 25K in-tool
truncation and is also compacted immediately post-exchange (see ContextCompactor).

15_000
WEB_FETCH_MAX_CHARS =

Higher limit for web fetch results. HTML-stripped plain text is dense and
a product/category page can have many items. Sonnet 4.6 has a 200K token
context window, so 60K chars (~15K tokens) is well within budget.

60_000
MAX_SQL_ROWS =

Maximum rows returned by execute_sql (wide tables can generate huge JSON)

50
VIDEO_TRANSCRIPT_HARD_CAP =

Safety backstop for a video transcript returned by get_video (include:
["transcript"]) — NOT a routine limit. Real transcripts top out at ~88K
chars (429 videos; p99 ~51K; exactly one over 80K), so at ~3x the observed
max NO real single-video transcript is ever clipped — the whole point of
get_video is to return them in full. This bound only guards against a
degenerate/corrupt row producing a multi-MB string that would blow the
context window. The model opts into the transcript explicitly and sees
transcript_chars first, so it already controls the size; if this bound ever
trips, the response head-slices and sets transcript_truncated.

250_000
CHAT_CONTENT_TOOLS =
Note:

find_call_recordings is built inline (build_call_recordings_tool) because the
MCP class reads Thread.current[:mcp_auth_result], which is not set when invoked from
the Sunny tool loop — every call returned "Authentication required" before this
was split out (10-day audit: 7/7 = 100% failure).

Content tools exposed to the AI Assistant chat.
The MCP server exposes all ApplicationTool descendants (for external clients like Cursor),
but the chat only needs a focused set to avoid tool-choice confusion for the LLM:

  • semantic_search: primary content discovery (posts, showcases, videos, products, etc.)
  • find_images: dedicated image search with rich URL output (CRM, ImageKit, HTML tags, thumbnails)
  • find_faqs: FAQ-specific search with product_line filtering and answer content
  • find_publications: freshness-gated manuals/publications with public citation URLs
    Removed: find_products and find_reviews — these remain thin wrappers around
    semantic_search. The LLM can use semantic_search with the types parameter
    instead (e.g. types: ["products"]).
%w[
  semantic_search get_showcase find_images find_faqs find_publications create_faq update_faq
].freeze
SUPPORT_CASE_TOOLS =

Support case tools.

%w[find_support_cases search_support_notes get_support_case].freeze
AD_PLATFORM_KEYS =

Ad-platform service keys. Each drives a different live ad account, so a
step naming one must never be routed to another — see
tool_service_keys_for_step.

%w[google_ads microsoft_ads adlabs].freeze
NAMED_AD_PLATFORM_KEYS =

The platforms that name themselves unambiguously in a step ("Google Ads",
"Bing"). AdLabs is absent by design: its pattern is the generic PPC
vocabulary, so it can only be inferred, never used to override.

%w[google_ads microsoft_ads].freeze
TOOL_SERVICE_MAP =

Reverse mapping: tool name pattern → service key.
Used to detect which services a conversation needs based on its tool call history.
Order matters: more specific patterns first.
Includes legacy 'postgres_production_*' names for conversations created before the rename.

[
  { pattern: /\Aapp_db_/,              service: 'app_db' },
  { pattern: /\Apostgres_production_/, service: 'app_db' },
  { pattern: /\Aversions_db_/,         service: 'postgres_versions' },
  { pattern: /\Apostgres_versions_/,   service: 'postgres_versions' },
  { pattern: /\Aga4_/,                 service: 'google_analytics' },
  { pattern: /\Agsc_/,                 service: 'search_console' },
  { pattern: /\Agoogle_ads_/,          service: 'google_ads' },
  { pattern: /\Amicrosoft_ads_/,       service: 'microsoft_ads' },
  { pattern: /\Aahrefs_/,              service: 'ahrefs' },
  { pattern: /\A(read_pinterest_pins|list_pinterest_boards)\z/, service: 'pinterest' },
  { pattern: /\Adatadive_/, service: 'amazon_datadive' },
  # AdLabs tools keep their upstream (un-prefixed) names so the server's own
  # operating instructions stay coherent; enumerate them for history routing.
  { pattern: /\A(?:
    start_chat_session|read_resource|get_entity_data|query|group_by_column|read|
    download_data|list_active_references|create_goto_link|analyze|context_and_prompts|
    submit_bug_report|create_entities|update_entities|optimizer|tags|logs
  )\z/x, service: 'adlabs' },
  { pattern: /\A(list|create|update|get|patch)_blog_(post|tags)\z/, service: 'blog_management' },
  { pattern: /\Arefresh_blog_oembeds\z/, service: 'blog_management' },
  {
    pattern: /\A(?:
      list_technical_articles|get_technical_article_for_editing|
      create_technical_article_(?:draft|replacement)|update_technical_article_draft|
      transition_technical_article|(?:add|remove)_technical_article_related_material|
      merge_technical_articles
    )\z/x,
    service: 'technical_article_management'
  },
  {
    pattern: /\A(create_showcase|update_showcase|delete_showcase|set_showcase_images|set_showcase_faqs)\z/,
    service: 'showcase_management'
  },
  { pattern: /\A(list|get|create|update|edit|clone|preview)_email_template(s)?\z/, service: 'email_management' },
  { pattern: /\Aget_email_block_html\z/, service: 'email_management' },
  { pattern: /\Agenerate_final_html\z/, service: 'email_management' },
  { pattern: /\Ainsert_email_/, service: 'email_management' },
  { pattern: /\A(list_audiences|get_audience|create_static_audience|create_dynamic_audience|update_audience|add_audience_members|remove_audience_members|estimate_audience_size)\z/, service: 'email_management' },
  { pattern: /\A(get|update)_email_preferences\z/, service: 'email_management' },
  { pattern: /\A(list_campaigns|create_campaign|lookup_campaign_emails|create_campaign_email|schedule_campaign_email)\z/, service: 'email_management' },
  { pattern: /\A(list_employee_emails|create_communication_draft)\z/, service: 'email_management' },
  { pattern: /\A(campaign_email_report|campaign_report)\z/, service: 'email_management' },
  { pattern: /\A(describe_customer_search|list_saved_customer_searches|run_customer_search|create_customer_search|update_customer_search)\z/, service: 'customer_search' },
  { pattern: /\A(draft_customer_email|schedule_customer_email|list_my_customer_emails)\z/, service: 'email_compose' },
  { pattern: /\Ainsert_/, service: 'blog_management' },
  { pattern: /\A(list_product_lines|get_product|search_products|browse_product_line|search_specs|get_spec|get_spec_scope)\z/, service: 'product_catalog' },
  { pattern: /\A(update_catalog_item_state|update_product|update_product_line|get_product_content|get_product_line_content)\z/, service: 'product_management' },
  { pattern: /\A(update_spec|clone_spec_to_|enqueue_spec_refresh)\w*\z/, service: 'product_spec_management' },
  { pattern: /\Akwpu_/, service: 'keywords_people_use' },
  { pattern: /\Abasecamp_/,          service: 'basecamp' },
  { pattern: /\Aweather_/,           service: 'weather' },
  { pattern: /\Afind_images\z/, service: 'image_management' },
  { pattern: /\A(create_faq|update_faq|publish_faqs)\z/, service: 'faq_management' },
  { pattern: /\Asearch_activity_notes\z/, service: 'app_db' },
  { pattern: /\A(find_support_cases|search_support_notes|get_support_case)\z/, service: 'support_cases' },
  { pattern: /\A(semantic_search|get_showcase|find_faqs|find_publications|get_publication|find_call_recordings|search_(?:technical|procedure)_articles|get_(?:technical|procedure)_article)\z/, service: 'content' },
  { pattern: /\A(search|list)_my_conversations\z/, service: 'conversation_memory' },
  { pattern: /\Afetch_url\z/,                      service: 'web_fetch' },
  { pattern: /\Aweb_search\z/,                     service: 'web_search' },
  { pattern: /\Aseo_/,                             service: 'seo_audit' },
  { pattern: /\Agamma_/,                           service: 'gamma' },
  { pattern: /\Apdf_/,                             service: 'pdf_tools' },
  { pattern: /\Aextract_document\z/,               service: 'pdf_tools' },
  { pattern: /\Asearch_brain\z/, service: 'content' },
  { pattern: /\Apropose_brain_entry\z/, service: 'brain_management' },
  { pattern: /\A(find_employee|get_team_availability|get_pipeline_summary|get_rep_workload|get_rep_performance|get_recent_calls|get_opportunity_brief|get_customer_brief)\z/, service: 'sales_management' }
].freeze
TECHNICAL_ARTICLE_SERVICE_PATTERN =

Matches plan steps that involve technical articles.

/technical.?articles?|troubleshooting (?:guides?|sources?)|knowledge consolidation/i
BLOG_SERVICE_PATTERN =

Matches plan steps that involve blog posts.

%r{\bblog|blog.?post|/posts/}i
CUSTOMER_SEARCH_SERVICE_PATTERN =

Matches plan steps that involve the advanced customer search tool.

/\b(?:customer|advanced|saved customer) search(?:es)?\b/i
STICKY_SERVICE_PATTERNS =

Service keys that, once mentioned anywhere in a plan, should remain
available for every step so the model doesn't lose access mid-plan.

[
  [TECHNICAL_ARTICLE_SERVICE_PATTERN, %w[technical_article_management]],
  [/blog|post|article|patch|oembed/i, %w[blog_management]],
  [/showcase/i, %w[showcase_management]],
  [/email template|email campaign|newsletter|email design|email blast/i, %w[email_management]],
  [CUSTOMER_SEARCH_SERVICE_PATTERN, %w[customer_search]],
  [/seo|action.?item|recommendation|sitemap/i, %w[seo_audit]]
].freeze
ROLE_DEFAULTS =

Core services always available (role-based defaults below add DB access).
External tool services (google_analytics, etc.) are resolved separately
from DataDomainPolicy.tool_services_for and merged in default_services_for.

{
  admin:    %w[conversation_memory content product_catalog app_db postgres_versions web_fetch web_search seo_audit brain_management sales_management],
  manager:  %w[conversation_memory content product_catalog app_db web_fetch web_search seo_audit brain_management sales_management],
  employee: %w[conversation_memory content product_catalog app_db web_fetch web_search brain_management]
}.freeze
POWER_USER_ROLES =

Role classification for DB tool access.
Power users get full schema exploration tools; restricted users
get only describe_available_data + a restricted execute_sql.

%i[admin manager].freeze
STEP_DESCRIPTION_SERVICE_PATTERNS =

Build RubyLLM::Tool instances for the given service keys
param service_keys [Array] e.g. ['content', 'app_db']
param role [Symbol] :admin, :manager, or :employee (controls DB tool access)
param allowed_objects [Set, nil] domain-resolved set of allowed view/table names (nil = unrestricted)
param audit_context [Hash] optional context for SQL audit logging
param account [Account, nil] the CRM account (needed for per-account services like Basecamp)
Heuristic mapping: step description substring → tool service keys (for isolated plan-step execution).
Intersected with the user's permitted services. If nothing matches, full +service_keys+ is used.

[
  [/email template|email campaign|newsletter|email design|email blast|email copy/i, %w[email_management content]],
  [CUSTOMER_SEARCH_SERVICE_PATTERN, %w[customer_search]],
  [TECHNICAL_ARTICLE_SERVICE_PATTERN, %w[technical_article_management content support_cases]],
  [/blog|post|article|content|patch|oembed|table|alt.?text|heading|h[1-6]|title|summary|paragraph|internal.?link|comparison|intro|conclusion|rewrite|edit|update.*content/i, %w[blog_management content]],
  [/showcase|project (page|showcase|gallery)/i, %w[showcase_management content]],
  [/sql|query|database|kpi|sales|order|customer|execute_sql/i, %w[app_db]],
  [/seo|sitemap|inbound|outbound|schema|action.?item/i, %w[seo_audit]],
  [/image|photo|picture|thumbnail/i, %w[image_management content]],
  [/faq/i, %w[faq_management content]],
  [/search|semantic|find_|look.?up|keyword/i, %w[content product_catalog]],
  # Editing product / product-line copy needs the write service (gated to
  # item_manager) plus catalog reads to fetch the current text first.
  [/(update|edit|change|rewrite|revise|translate|localize|set).{0,30}(product|product.?line|sku|catalog|description|title|name|seo|feature|copy|content)/i, %w[product_catalog product_management]],
  [/product|sku|catalog|spec|towel|heating/i, %w[product_catalog]],
  [/gamma|presentation|deck/i, %w[gamma]],
  [/basecamp/i, %w[basecamp]],
  [/weather/i, %w[weather]],
  # Before the generic /fetch|url|http/ row: a step that says "search the
  # web / google for X" needs the SERP tool, not just the URL reader.
  [/web.?search|serp\b|google (?:search|results?)|search (?:the )?(?:web|internet|online)/i, %w[web_search web_fetch]],
  [/fetch|url|http/i, %w[web_fetch]],
  [/ga4|analytics|traffic/i, %w[google_analytics]],
  [/search.?console|gsc/i, %w[search_console]],
  [/google.?ads/i, %w[google_ads]],
  [/\b(?:microsoft.?ads|bing|msan)\b/i, %w[microsoft_ads]],
  [/ahrefs|backlink/i, %w[ahrefs]],
  [/pinterest|\bpins?\b/i, %w[pinterest]],
  [/datadive|rank.?radar|ranking.?juice|\basin\b|amazon.*(niche|keyword|seller|inventory|competitor)/i, %w[amazon_datadive]],
  [/adlabs|amazon ads|amazon advertising|\bppc\b|\bacos\b|\btacos\b|\broas\b|bid optimi|sponsored (product|brand|display)|negative (keyword|target)|search.?term report/i, %w[adlabs]],
  [/support.?case|ticket/i, %w[support_cases]],
  [/brain|rule/i, %w[brain_management content]],
  [/employee|pipeline|rep\.|sales.?team|opportunit(?:y|ies)|customer.?brief|opportunity.?brief|\bON\d{4,}\b|\bCN\d{4,}\b/i, %w[sales_management app_db]]
].freeze
TERSE_MARKER_BELOW_CHARS =

Below this budget the explanatory marker is a fifth of the whole
allowance, so it collapses to the terse form — advice the payload can't
afford to carry is worse than no advice.

2_000
ARRAY_WIDEN_CAP =

Ceiling for widen_arrays' doubling — matches the widest ladder rung, so
widening can restore a full array but never exceed what the untrimmed
top of the ladder would have kept.

200

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.chat_servicesHash{String => Hash}

Services that can be offered in the assistant chat.

Returns:

  • (Hash{String => Hash})

    mapping of service key to label, handle, icon, and description



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'app/services/assistant/chat_tool_builder.rb', line 246

def self.chat_services
  {
    'conversation_memory' => { label: 'My Conversations', handle: 'conversation-memory', icon: 'fa-clock-rotate-left', description: 'Search past conversations to recall previous analyses, findings, and answers' },
    'content' => { label: 'Content Search', handle: 'content-search', icon: 'fa-magnifying-glass', description: 'Search products, FAQs, posts, and more' },
    'image_management' => { label: 'Image Management', handle: 'image-management', icon: 'fa-images', description: 'Search and manage the image library with keyword, AI, or hybrid modes' },
    'faq_management' => { label: 'FAQ Management', handle: 'faq-management', icon: 'fa-circle-question', description: 'Search, create, and manage FAQ articles with product line and tag associations' },
    'technical_article_management' => {
      label: 'Technical Articles',
      handle: 'technical-articles',
      icon: 'fa-screwdriver-wrench',
      description: 'Draft, revise, review, relate evidence, replace, and consolidate Technical Articles'
    },
    'app_db' => {
      label: 'App DB', handle: 'app-db', icon: 'fa-database',
      description:
        'Read-only SQL queries against the application database — sales, orders, customers, ' \
        'campaigns, email marketing, link clicks, communications, inventory, KPIs, and more'
    },
    'postgres_versions' => { label: 'Versions DB', handle: 'versions-db', icon: 'fa-clock-rotate-left', description: 'Read-only queries against audit trail' },
    'google_analytics' => { label: 'Google Analytics', handle: 'google-analytics', icon: 'fa-chart-line', description: 'GA4 page views, sessions, engagement, traffic sources' },
    'search_console' => { label: 'Search Console', handle: 'search-console', icon: 'fa-magnifying-glass-chart', description: 'Google search clicks, impressions, CTR, positions' },
    'google_ads' => { label: 'Google Ads', handle: 'google-ads', icon: 'fa-rectangle-ad', description: 'Keyword volume, campaign data, GAQL queries' },
    'microsoft_ads' => { label: 'Microsoft Ads', handle: 'microsoft-ads', icon: 'fa-rectangle-ad',
description: 'Bing/Microsoft Ads — campaign, keyword and search-term performance, budgets and bids; changing budgets, bids and negative keywords requires the microsoft_ad_specialist role or admin access' },
    'ahrefs' => { label: 'Ahrefs SEO', handle: 'ahrefs', icon: 'fa-link', description: 'Backlinks, organic traffic, keyword rankings' },
    'pinterest' => { label: 'Pinterest', handle: 'pinterest', icon: 'fa-pinterest', description: 'Read existing Pinterest pins and boards from the WarmlyYours brand account — pin content, destination links, images, and boards (read-only)' },
    'amazon_datadive' => {
      label: 'Amazon DataDive', handle: 'amazon-datadive', icon: 'fa-store',
      description: 'Amazon product research via DataDive — Niche keyword lists, competitor stats, Ranking Juice, ' \
                   'Rank Radar keyword-ranking trends, and per-fulfillment-center seller inventory by ASIN'
    },
    'adlabs' => {
      label: 'Amazon Ads (AdLabs)', handle: 'adlabs', icon: 'fa-bullseye',
      description: 'Amazon ADVERTISING / PPC only, via the external AdLabs API — campaigns, ad groups, ' \
                   'keywords, search-term reports, bids, and ad performance (ad spend, ad-attributed sales, ' \
                   'ACOS, ROAS). NOT a source for overall Amazon order volume or sales revenue — that internal ' \
                   'commerce data lives in app_db. Use ONLY when the query is explicitly about Amazon ad ' \
                   'campaigns/PPC. Writes (targets, bid optimizations, tags, reverting jobs) act on the live ' \
                   'Amazon Ads account and require the Amazon Ad Specialist role'
    },
    'product_catalog' => {
      label: 'Product Catalog', handle: 'product-catalog', icon: 'fa-box-open',
      description: 'Look up product SKUs, search the catalog, browse product lines — with live pricing, ' \
                   'availability, and rendering flags. Also use for product compatibility questions: which ' \
                   'heating system is best for a given room type, floor covering, or installation scenario ' \
                   '(e.g. "best floor heating for a sunroom", "what works under tile", ' \
                   '"recommendation for concrete subfloor")'
    },
    'product_management' => { label: 'Product Management', handle: 'product-management', icon: 'fa-toggle-on', description: 'Edit product & product-line text (names, descriptions, SEO) in any locale, plus catalog state transitions' },
    'product_spec_management' => { label: 'Spec Management', handle: 'spec-management', icon: 'fa-sliders', description: 'Search, inspect, update, and clone product specifications — item_manager only' },
    'blog_management' => { label: 'Blog Management', handle: 'blog-management', icon: 'fa-pen-to-square', description: 'Create, update, and manage blog posts with embedded images, videos, FAQs, and products' },
    'showcase_management' => { label: 'Showcase Management', handle: 'showcase-management', icon: 'fa-panorama', description: 'Create, edit, and archive project showcases — details, specs, linked images, FAQs, and product lines' },
    'email_management' => { label: 'Email Management', handle: 'email-management', icon: 'fa-envelope',
                            description: 'Create and edit Redactor 4 email templates in our design system, ' \
                                         'embed email-safe images, buttons, and product cards, and preview before sending' },
    'email_compose' => { label: 'Email Compose', handle: 'email-compose', icon: 'fa-paper-plane',
                         description: 'Draft a one-off email to one of YOUR OWN customers (follow-up, quote nudge, ' \
                                      'trade-show hello) — draft or schedule only, never bulk, never instant send' },
    'customer_search' => { label: 'Customer Search', handle: 'customer-search', icon: 'fa-magnifying-glass',
                           description: 'CRM advanced customer search: discover filter vocabulary and value ranges, ' \
                                        'dry-run criteria, and save/manage your own saved searches' },
    'keywords_people_use' => { label: 'KeywordsPeopleUse', handle: 'keywords-people-use', icon: 'fa-key', description: 'Keyword research, People Also Ask questions, and autocomplete suggestions' },
    'basecamp' => { label: 'Basecamp', handle: 'basecamp', icon: 'fa-campground', description: 'Projects, todos, search, and team collaboration' },
    'weather'  => { label: 'Weather', handle: 'weather', icon: 'fa-cloud-sun', description: 'Current conditions, forecasts, and historical weather for any location via Visual Crossing' },
    'web_fetch' => { label: 'Web Fetch', handle: 'web-fetch', icon: 'fa-globe', description: 'Fetch and read the content of any public URL (web pages, articles, docs, competitor sites)' },
    'web_search' => { label: 'Web Search', handle: 'web-search', icon: 'fa-magnifying-glass',
                      description: 'Search the public web (Google) for pages, competitors, facts, and current ' \
                                   'information — returns result titles, URLs, and snippets to read with Web Fetch' },
    'seo_audit' => {
      label: 'SEO Audit', handle: 'seo-audit', icon: 'fa-network-wired',
      description: 'SEO action items and recommendations — view, update status (mark completed/pending), ' \
                   'internal link graph, page crawl data, AI SEO reports, link gap analysis, and orphan page detection'
    },
    'gamma' => { label: 'Gamma', handle: 'gamma', icon: 'fa-display', description: 'Create AI-generated presentations, documents, social posts, and webpages with Gamma' },
    'pdf_tools' => {
      label: 'PDF & Documents', handle: 'pdf-tools', icon: 'fa-file-pdf',
      description: 'Inspect, edit, fill, merge, split, rotate, compress, and generate PDFs — ' \
                   'overlay or redact text on an attached PDF (e.g. update a phone number), fill ' \
                   'form fields, assemble pages, or build a new branded document — and extract ' \
                   'any attached document (PDF incl. scanned, Word, Excel, images) to clean ' \
                   'Markdown with tables intact for question answering'
    },
    'support_cases' => { label: 'Support Cases', handle: 'support-cases', icon: 'fa-headset', description: 'Search support cases, activity notes, and communications for customer issues, complaints, and resolutions' },
    'brain_management' => { label: 'Brain Management', handle: 'brain-management', icon: 'fa-brain', description: 'Propose new learned rules for the Sunny brain when you self-correct or receive domain feedback' },
    'sales_management' => {
      label: 'Sales Management', handle: 'sales-management', icon: 'fa-users-gear',
      description: 'Sales force management tools — look up employees by name, check who is working today, ' \
                   'review deal pipelines by rep, assess activity workloads, get performance snapshots, ' \
                   'and review recent call activity'
    }
  }.freeze
end

.default_services_for(account) ⇒ Array<String>

Determine default tool services for a given account based on role.
Combines core services (DB/content) with external tool services from DataDomainPolicy.

Parameters:

  • account (Account)

    account whose role determines the default service set

Returns:

  • (Array<String>)

    default service keys



476
477
478
479
480
481
482
483
484
485
486
487
488
489
# File 'app/services/assistant/chat_tool_builder.rb', line 476

def self.default_services_for()
  core = if .is_admin?
           ROLE_DEFAULTS[:admin]
         elsif .is_manager?
           ROLE_DEFAULTS[:manager]
         else
           ROLE_DEFAULTS[:employee]
         end

  # Merge in external tool services (google_analytics, ahrefs, etc.)
  tool_services = Assistant::DataDomainPolicy.tool_services_for(account: )

  (core + tool_services).uniq
end

.role_for(account) ⇒ Symbol

Determine the role tier for an account (for DB tool access control).

Parameters:

  • account (Account)

    account whose role tier to determine

Returns:

  • (Symbol)

    :admin, :manager, or :employee



495
496
497
498
499
500
501
502
503
# File 'app/services/assistant/chat_tool_builder.rb', line 495

def self.role_for()
  if .is_admin?
    :admin
  elsif .is_manager?
    :manager
  else
    :employee
  end
end

.service_for_tool(tool_name) ⇒ String?

Resolve a tool name to its service key.

Parameters:

  • tool_name (String)

    e.g. "app_db_execute_sql"

Returns:

  • (String, nil)

    service key or nil if unknown



227
228
229
# File 'app/services/assistant/chat_tool_builder.rb', line 227

def self.service_for_tool(tool_name)
  TOOL_SERVICE_MAP.find { |entry| tool_name.match?(entry[:pattern]) }&.dig(:service)
end

.service_key_for_handle(handle) ⇒ String?

Resolve a UI chip / @mention handle (kebab-case) to a service key.
Used by ToolRouter so badge hidden inputs always map to catalog entries
(avoids drift when a new service is added to +chat_services+ but MENTION_ALIASES lags).

Parameters:

  • handle (String)

    e.g. "seo-audit", "web-fetch"

Returns:

  • (String, nil)

    service key e.g. "seo_audit", or nil if unknown



345
346
347
348
349
350
# File 'app/services/assistant/chat_tool_builder.rb', line 345

def self.service_key_for_handle(handle)
  return nil if handle.blank?

  h = handle.to_s.strip.downcase
  service_keys_by_handle[h]
end

.service_keys_by_handleHash{String => String}

Reverse lookup from UI handle to service key.

Returns:

  • (Hash{String => String})

    handle (kebab-case) → service key



355
356
357
358
359
# File 'app/services/assistant/chat_tool_builder.rb', line 355

def self.service_keys_by_handle
  @service_keys_by_handle ||= chat_services.each_with_object({}) do |(key, meta), acc|
    acc[meta[:handle].to_s.downcase] = key
  end
end

.services_from_history(conversation) ⇒ Array<String>

Detect all service keys referenced by tool calls in a conversation.
Queries through assistant_messages → assistant_tool_calls to find unique tool names.

Parameters:

Returns:

  • (Array<String>)

    unique service keys



235
236
237
238
239
240
241
# File 'app/services/assistant/chat_tool_builder.rb', line 235

def self.services_from_history(conversation)
  tool_names = AssistantToolCall
               .joins(:assistant_message)
               .where(assistant_messages: { assistant_conversation_id: conversation.id })
               .distinct.pluck(:name)
  tool_names.filter_map { |name| service_for_tool(name) }.uniq
end

.sticky_service_keys_for_plan(steps, permitted_keys, goal: nil) ⇒ Array<String>

Returns service keys that should persist across plan steps.

Parameters:

  • steps (Array<Hash, String>)

    the plan's step descriptions

  • permitted_keys (Array<String>)

    the caller's enabled tool services

  • goal (String, nil) (defaults to: nil)

    optional plan goal — scanned alongside step
    descriptions because the goal is usually the most reliable anchor
    of what the plan is about (the editor's wording, not the model's
    step rephrasing). Conversation 1657 lost blog_management on step 3
    ("Move the final CTA block before the FAQ block") despite the goal
    explicitly saying "blog post #5122".

Returns:

  • (Array<String>)

    service keys that should persist across plan steps



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

def self.sticky_service_keys_for_plan(steps, permitted_keys, goal: nil)
  keys = Array(permitted_keys).map(&:to_s).uniq
  return [] if keys.empty?

  texts = []
  texts << goal.to_s if goal.present?
  Array(steps).each do |step|
    texts << (step.is_a?(Hash) ? step['description'].to_s : step.to_s)
  end
  return [] if texts.all?(&:blank?)

  sticky = []
  texts.each do |desc|
    STICKY_SERVICE_PATTERNS.each do |re, svc_keys|
      sticky.concat(svc_keys) if desc.match?(re)
    end
  end
  technical_work = texts.any? { |text| text.match?(TECHNICAL_ARTICLE_SERVICE_PATTERN) }
  explicit_blog_work = texts.any? { |text| text.match?(BLOG_SERVICE_PATTERN) }
  sticky.delete('blog_management') if technical_work && !explicit_blog_work
  (sticky.uniq & keys)
end

.tool_service_keys_for_step(step_description, permitted_keys, sticky_keys: []) ⇒ Array<String>

Pick a subset of service keys for a single plan step based on the step
description. Intersects with the caller's enabled services and merges any
sticky keys that should persist across the plan.

Parameters:

  • step_description (String, nil)

    the plan step's description text

  • permitted_keys (Array<String>)

    caller's enabled tool services

  • sticky_keys (Array<String>) (defaults to: [])

    service keys that persist across steps

Returns:

  • (Array<String>)

    non-empty subset of +permitted_keys+



401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
# File 'app/services/assistant/chat_tool_builder.rb', line 401

def self.tool_service_keys_for_step(step_description, permitted_keys, sticky_keys: [])
  keys = Array(permitted_keys).map(&:to_s).uniq
  return keys if step_description.blank? || keys.empty?

  desc = step_description.to_s
  matched = []
  STEP_DESCRIPTION_SERVICE_PATTERNS.each do |re, svc_keys|
    matched.concat(svc_keys) if desc.match?(re)
  end
  matched.uniq!
  technical_only = matched.include?('technical_article_management') && !desc.match?(BLOG_SERVICE_PATTERN)
  matched.delete('blog_management') if technical_only
  filtered_match = technical_only

  # A pinterest.com / pin.it URL also matches the generic /http|url/ web_fetch
  # pattern, but fetch_url can't read Pinterest (JS-rendered, scrape-blocked) —
  # that failure is the whole reason the Pinterest reader exists. Route Pinterest
  # URL steps to the reader only. Non-URL Pinterest references keep web_fetch.
  pinterest_url = matched.include?('pinterest') && desc.match?(%r{\b(?:pinterest\.com|pin\.it)/}i)
  matched.delete('web_fetch') if pinterest_url
  filtered_match ||= pinterest_url

  # The AdLabs pattern claims the generic PPC vocabulary — "negative keyword",
  # "search term report", "bid optimi", roas/acos/ppc — which is exactly how a
  # Google Ads or Bing step reads too ("add a negative keyword to Google Ads").
  # Matching is additive, so such a step would select BOTH platforms and let
  # the model write to the wrong live ad account. A platform named explicitly
  # wins over the inferred one, unless the step also names Amazon/AdLabs (a
  # genuine cross-platform ask).
  named_non_amazon_ads = matched.intersect?(NAMED_AD_PLATFORM_KEYS) && !desc.match?(/\b(?:amazon|adlabs)\b/i)
  matched.delete('adlabs') if named_non_amazon_ads
  filtered_match ||= named_non_amazon_ads

  return keys if matched.empty?

  intersection = matched & keys

  # The `|| keys` fallback means "nothing specific matched, so allow
  # everything permitted" — but a step that DID name an ad platform the user
  # can't reach must not fall through to the other one. Without this, "Check
  # Microsoft Ads ROAS" on an adlabs-only account intersects to [] and the
  # fallback hands back adlabs — the exact wrong-live-account routing the
  # precedence rule above exists to prevent. Fall back to the permitted
  # non-platform keys instead (which is [] when only a platform was permitted).
  fallback = if filtered_match
               []
             elsif matched.intersect?(AD_PLATFORM_KEYS)
               keys - AD_PLATFORM_KEYS
             else
               keys
             end
  result = intersection.presence || fallback

  # Merge in sticky keys (e.g. blog_management when any plan step is blog-related)
  sticky = Array(sticky_keys) & keys
  result = (result + sticky).uniq if sticky.any?

  # Isolated SEO steps often need blog slug resolution (list_blog_posts,
  # get_blog_post). Those tools live in blog_management, not content.
  # Prefer blog_management when available; fall back to content for
  # semantic_search if blog_management isn't permitted.
  if result.include?('seo_audit')
    if keys.include?('blog_management') && result.exclude?('blog_management')
      result = (result + ['blog_management']).uniq
    elsif keys.include?('content') && result.exclude?('content')
      result = (result + ['content']).uniq
    end
  end
  result
end

.tools_for_services(service_keys, role: :employee, allowed_objects: nil, audit_context: {}, account: nil, provider: :anthropic, include_plan_tools: true) ⇒ Array<RubyLLM::Tool>

Returns instantiated tool objects ready for +chat.with_tools+.

Parameters:

  • service_keys (#to_a)

    list of service key strings (e.g. %w[content app_db])

  • role (Symbol) (defaults to: :employee)

    :admin, :manager, or :employee (DB tool access control)

  • allowed_objects (Set, nil) (defaults to: nil)

    domain-resolved allowed view/table names, or nil for unrestricted

  • audit_context (Hash) (defaults to: {})

    optional keys such as +:conversation_id+, +:user_id+ for SQL audit logging

  • account (Account, nil) (defaults to: nil)

    CRM account when a builder needs account context (e.g. Basecamp)

  • provider (Symbol) (defaults to: :anthropic)

    LLM provider (:anthropic, :gemini, :openai); guards +cache_control+ on Anthropic only

  • include_plan_tools (Boolean) (defaults to: true)

    when false, omit declare_plan / mark_step_complete (plan step executor)

Returns:

  • (Array<RubyLLM::Tool>)

    instantiated tool objects ready for +chat.with_tools+



513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
# File 'app/services/assistant/chat_tool_builder.rb', line 513

def self.tools_for_services(service_keys, role: :employee, allowed_objects: nil, audit_context: {}, account: nil, provider: :anthropic, include_plan_tools: true)
  tools = service_keys.flat_map do |key|
    build_service_tools(key, role: role, allowed_objects: allowed_objects, audit_context: audit_context, account: )
  end.compact

  conversation_id = audit_context[:conversation_id]
  tools.concat(Assistant::PlanToolBuilder.tools(conversation_id: conversation_id)) if tools.any? && conversation_id.present? && include_plan_tools

  # restore_tool_result: expand an earlier tool result that ContextCompactor
  # truncated. Gated by the same flag that stashes the originals — no point
  # offering the tool when nothing is being stashed. Added before the cache
  # breakpoint below so the marker stays on a stable last tool.
  tools << Assistant::RestoreToolResultBuilder.tool(conversation_id) if Assistant::ContextCompactor.restore_enabled? && conversation_id.present? && tools.any?

  if provider == :anthropic && tools.any?
    # Strict tool use: tools that opt in via #strict_tool? (the block-addressed
    # editors) get grammar-constrained inputs — Anthropic guarantees the args
    # validate against input_schema, so no malformed edit ops. Anthropic-only:
    # the Gemini/OpenAI serializers also deep-merge provider_options, and a
    # top-level `strict` key is invalid in their declaration shapes.
    tools.map! do |tool|
      tool.respond_to?(:strict_tool?) && tool.strict_tool? ? with_provider_options(tool, 'strict' => true) : tool
    end

    # Mark the last tool with an Anthropic cache_control breakpoint so the
    # entire tool array (including STYLE_GUIDE descriptions) is cached across
    # turns.
    #
    # Thread-safety: tool builder methods are memoized (@content_tools ||= ...),
    # so the same tool CLASS and instances are shared across every Puma/Sidekiq
    # thread and conversation. Setting cache_control on that shared class/instance
    # state races with concurrent requests that have a different tool set — stale
    # markers, a breakpoint on the wrong tool, or >4 blocks in one request (a 400).
    # Instead we add the breakpoint to a per-request dup of the last tool via a
    # singleton method; the dup is local to this array and shares the original's
    # class, so name/description/schema/execute all still resolve. Shared state is
    # never mutated, so non-Anthropic providers (Gemini, OpenAI) need no clearing pass.
    tools[-1] = with_provider_options(
      tools.last, 'cache_control' => Assistant::PromptCache.cache_control(string_keys: true)
    )
  end

  tools
end

Instance Method Details

#build_gamma_toolsArray<RubyLLM::Tool>

Build Gamma presentation/document tools.

Returns:

  • (Array<RubyLLM::Tool>)

    Gamma tool instances



1433
1434
1435
# File 'app/services/assistant/chat_tool_builder.rb', line 1433

public def build_gamma_tools
  safe_build_tools('Gamma') { Assistant::GammaToolBuilder.tools }
end

#build_mcp_tools(tool_names) ⇒ Array<RubyLLM::Tool>

Load and wrap the requested ApplicationTool descendants as chat tools.

Parameters:

  • tool_names (Array<String>)

    MCP tool names to include

Returns:

  • (Array<RubyLLM::Tool>)

    wrapped content tool instances



1460
1461
1462
1463
1464
1465
# File 'app/services/assistant/chat_tool_builder.rb', line 1460

public def build_mcp_tools(tool_names)
  ensure_mcp_tools_loaded!(tool_names)
  ApplicationTool.descendants
                 .select { |mcp_class| tool_names.include?(mcp_class.tool_name) }
                 .filter_map { |mcp_class| wrap_content_tool(mcp_class) }
end

#ensure_mcp_tools_loaded!(tool_names = nil) ⇒ void

This method returns an undefined value.

Eager-load MCP tool files unless the requested tools are already loaded.

Parameters:

  • tool_names (Array<String>, nil) (defaults to: nil)

    specific tools to load, or nil to load any



1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
# File 'app/services/assistant/chat_tool_builder.rb', line 1471

public def ensure_mcp_tools_loaded!(tool_names = nil)
  # Guard on the SPECIFIC tools requested, not descendants.any?: with lazy
  # loading (dev, parallel test processes) one unrelated tool class being
  # loaded would skip the glob, leaving the requested tools missing from
  # descendants — and the caller's ||= memoization then pins that empty
  # set for the life of the process.
  loaded = ApplicationTool.descendants.map(&:tool_name)
  return if tool_names.present? ? (tool_names - loaded).empty? : loaded.any?

  Rails.root.glob('app/mcp/tools/**/*.rb').each { |tool_path| require_dependency tool_path }
end

#gamma_toolsArray<RubyLLM::Tool>

─── Gamma Tools ─────────────────────────────────────────────────

Returns:

  • (Array<RubyLLM::Tool>)

    memoized Gamma tool instances



1426
1427
1428
# File 'app/services/assistant/chat_tool_builder.rb', line 1426

public def gamma_tools
  @gamma_tools ||= build_gamma_tools
end

#normalize_schema(raw_schema) ⇒ Hash

Normalize MCP input_schema to RubyLLM-compatible JSON Schema.

Parameters:

  • raw_schema (Hash)

    the MCP tool input schema

Returns:

  • (Hash)

    RubyLLM-compatible JSON Schema



1419
1420
1421
1422
# File 'app/services/assistant/chat_tool_builder.rb', line 1419

public def normalize_schema(raw_schema)
  schema = raw_schema.deep_symbolize_keys
  { type: 'object' }.merge(schema)
end

#safe_build_tools(label) ⇒ Array<RubyLLM::Tool>

─── Shared Helpers ───────────────────────────────────────────────
Yield a tool build block, logging and returning an empty array on failure.

Parameters:

  • label (String)

    human-readable label for the tool group

Yield Returns:

  • (Array<RubyLLM::Tool>)

    the tools to return

Returns:

  • (Array<RubyLLM::Tool>)

    built tools, or an empty array on error



1449
1450
1451
1452
1453
1454
# File 'app/services/assistant/chat_tool_builder.rb', line 1449

public def safe_build_tools(label)
  yield
rescue StandardError => e
  Rails.logger.warn("[ChatToolBuilder] Failed to build #{label} tools: #{e.message}")
  []
end

#sales_management_toolsArray<RubyLLM::Tool>

─── Sales Management Tools ──────────────────────────────────────

Returns:

  • (Array<RubyLLM::Tool>)

    memoized sales management tool instances



1439
1440
1441
# File 'app/services/assistant/chat_tool_builder.rb', line 1439

public def sales_management_tools
  @sales_management_tools ||= safe_build_tools('sales management') { Assistant::SalesManagementToolBuilder.tools }
end

#truncate_result(result_str, max_chars: MAX_TOOL_RESULT_CHARS) ⇒ String

Caps a tool-result string to max_chars while keeping the output as
valid JSON whenever the input itself is JSON. The previous version cut
the raw string at byte max_chars, which routinely produced malformed
JSON (mid-key or mid-value chops) — the model could not parse the
response and either silently dropped the data or wandered into a
retry-with-LIMIT loop. Now: parse → trim long strings + drop trailing
array elements until the serialized form fits → re-serialize. If the
input is not parseable, fall back to a JSON-wrapped raw truncation so
the result is still structurally valid.

Parameters:

  • result_str (String)

    the raw tool result to truncate

  • max_chars (Integer) (defaults to: MAX_TOOL_RESULT_CHARS)

    maximum characters allowed in the result

Returns:

  • (String)

    the truncated result, still valid JSON when possible



1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
# File 'app/services/assistant/chat_tool_builder.rb', line 1204

public def truncate_result(result_str, max_chars: MAX_TOOL_RESULT_CHARS)
  return result_str if result_str.length <= max_chars

  original_length = result_str.length
  parsed = parse_truncate_input(result_str)

  if parsed
    serialized = compress_for_budget(parsed, max_chars: max_chars, original_length: original_length)
    return serialized if serialized && serialized.length <= max_chars
  end

  # Fallback when input is not JSON or compression couldn't fit it:
  # wrap the raw cut in a structurally valid JSON envelope so callers
  # still receive parseable output.
  wrap_raw_truncation(
    result_str, max_chars: max_chars, original_length: original_length, parseable: !parsed.nil?
  )
end