Class: Assistant::ChatToolBuilder
- Inherits:
-
Object
- Object
- Assistant::ChatToolBuilder
- 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 thetypesparameter
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
-
.chat_services ⇒ Hash{String => Hash}
Services that can be offered in the assistant chat.
-
.default_services_for(account) ⇒ Array<String>
Determine default tool services for a given account based on role.
-
.role_for(account) ⇒ Symbol
Determine the role tier for an account (for DB tool access control).
-
.service_for_tool(tool_name) ⇒ String?
Resolve a tool name to its service key.
-
.service_key_for_handle(handle) ⇒ String?
Resolve a UI chip / @mention handle (kebab-case) to a service key.
-
.service_keys_by_handle ⇒ Hash{String => String}
Reverse lookup from UI handle to service key.
-
.services_from_history(conversation) ⇒ Array<String>
Detect all service keys referenced by tool calls in a conversation.
-
.sticky_service_keys_for_plan(steps, permitted_keys, goal: nil) ⇒ Array<String>
Service keys that should persist across plan steps.
-
.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.
-
.tools_for_services(service_keys, role: :employee, allowed_objects: nil, audit_context: {}, account: nil, provider: :anthropic, include_plan_tools: true) ⇒ Array<RubyLLM::Tool>
Instantiated tool objects ready for +chat.with_tools+.
Instance Method Summary collapse
-
#build_gamma_tools ⇒ Array<RubyLLM::Tool>
Build Gamma presentation/document tools.
-
#build_mcp_tools(tool_names) ⇒ Array<RubyLLM::Tool>
Load and wrap the requested ApplicationTool descendants as chat tools.
-
#ensure_mcp_tools_loaded!(tool_names = nil) ⇒ void
Eager-load MCP tool files unless the requested tools are already loaded.
-
#gamma_tools ⇒ Array<RubyLLM::Tool>
─── Gamma Tools ─────────────────────────────────────────────────.
-
#normalize_schema(raw_schema) ⇒ Hash
Normalize MCP input_schema to RubyLLM-compatible JSON Schema.
-
#safe_build_tools(label) ⇒ Array<RubyLLM::Tool>
─── Shared Helpers ─────────────────────────────────────────────── Yield a tool build block, logging and returning an empty array on failure.
-
#sales_management_tools ⇒ Array<RubyLLM::Tool>
─── Sales Management Tools ──────────────────────────────────────.
-
#truncate_result(result_str, max_chars: MAX_TOOL_RESULT_CHARS) ⇒ String
Caps a tool-result string to
max_charswhile keeping the output as valid JSON whenever the input itself is JSON.
Class Method Details
.chat_services ⇒ Hash{String => Hash}
Services that can be offered in the assistant chat.
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.
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(account) core = if account.is_admin? ROLE_DEFAULTS[:admin] elsif account.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: account) (core + tool_services).uniq end |
.role_for(account) ⇒ Symbol
Determine the role tier for an account (for DB tool access control).
495 496 497 498 499 500 501 502 503 |
# File 'app/services/assistant/chat_tool_builder.rb', line 495 def self.role_for(account) if account.is_admin? :admin elsif account.is_manager? :manager else :employee end end |
.service_for_tool(tool_name) ⇒ String?
Resolve a tool name to its service key.
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).
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_handle ⇒ Hash{String => String}
Reverse lookup from UI handle to 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, ), acc| acc[[: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.
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.
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.
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+.
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: 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? ? (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] = ( tools.last, 'cache_control' => Assistant::PromptCache.cache_control(string_keys: true) ) end tools end |
Instance Method Details
#build_gamma_tools ⇒ Array<RubyLLM::Tool>
Build Gamma presentation/document tools.
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.
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.
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_tools ⇒ Array<RubyLLM::Tool>
─── Gamma Tools ─────────────────────────────────────────────────
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.
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.
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.}") [] end |
#sales_management_tools ⇒ Array<RubyLLM::Tool>
─── Sales Management Tools ──────────────────────────────────────
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.
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 |