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
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
    Removed: find_products, find_publications, find_reviews — these are thin wrappers
    around semantic_search with richer formatting. The LLM can use semantic_search
    with the types parameter instead (e.g. types: ["products"]).
%w[semantic_search find_images find_faqs create_faq].freeze
SUPPORT_CASE_TOOLS =

Support case tools.

%w[find_support_cases search_support_notes get_support_case].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: /\Aahrefs_/,              service: 'ahrefs' },
  { pattern: /\Adatadive_/,            service: 'amazon_datadive' },
  { 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|get|create|update|edit|clone|preview)_email_template(s)?\z/, service: 'email_management' },
  { pattern: /\Agenerate_final_html\z/, service: 'email_management' },
  { pattern: /\Ainsert_email_/, service: 'email_management' },
  { 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: /\Aupdate_catalog_item_state\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: /\Acreate_faq\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|find_faqs|find_call_recordings)\z/, service: 'content' },
  { pattern: /\A(search|list)_my_conversations\z/, service: 'conversation_memory' },
  { pattern: /\Afetch_url\z/,                      service: 'web_fetch' },
  { pattern: /\Aseo_/,                             service: 'seo_audit' },
  { pattern: /\Agamma_/,                           service: 'gamma' },
  { pattern: /\Apdf_/,                             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
STICKY_SERVICE_PATTERNS =

Pick a subset of service keys for a plan step based on the step description.
param step_description [String]
param permitted_keys [Array] caller's enabled tool services (intersected)
Scan all plan step descriptions and return service keys that should
persist ("stick") across every step. When any step in a plan mentions
blog-related work, blog_management tools stay available for every step
so the model doesn't lose access mid-plan.

Returns:

  • (Array<String>)

    non-empty subset of +permitted_keys+

[
  [/blog|post|article|patch|oembed/i, %w[blog_management]],
  [/email template|email campaign|newsletter|email design|email blast/i, %w[email_management]],
  [/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 seo_audit brain_management sales_management],
  manager:  %w[conversation_memory content product_catalog app_db web_fetch seo_audit brain_management sales_management],
  employee: %w[conversation_memory content product_catalog app_db web_fetch 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]],
  [/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]],
  [/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]],
  [/product|sku|catalog|spec|towel|heating/i, %w[product_catalog]],
  [/gamma|presentation|deck/i, %w[gamma]],
  [/basecamp/i, %w[basecamp]],
  [/weather/i, %w[weather]],
  [/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]],
  [/ahrefs|backlink/i, %w[ahrefs]],
  [/datadive|rank.?radar|ranking.?juice|\basin\b|amazon.*(niche|keyword|seller|inventory|competitor)/i, %w[amazon_datadive]],
  [/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

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.chat_servicesObject

Services that can be offered in the assistant chat.



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'app/services/assistant/chat_tool_builder.rb', line 168

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' },
    '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' },
    'ahrefs' => { label: 'Ahrefs SEO', handle: 'ahrefs', icon: 'fa-link', description: 'Backlinks, organic traffic, keyword rankings' },
    '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'
    },
    '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: 'Activate, hide, or discontinue products by firing catalog item state transitions — item_manager only' },
    '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' },
    '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' },
    '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)' },
    '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 Tools', 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'
    },
    '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)

    the user's account record

Returns:

  • (Array<String>)

    default service keys



315
316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'app/services/assistant/chat_tool_builder.rb', line 315

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)

Returns:

  • (Symbol)

    :admin, :manager, or :employee



332
333
334
335
336
337
338
339
340
# File 'app/services/assistant/chat_tool_builder.rb', line 332

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



151
152
153
# File 'app/services/assistant/chat_tool_builder.rb', line 151

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



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

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_handleObject



244
245
246
247
248
# File 'app/services/assistant/chat_tool_builder.rb', line 244

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



159
160
161
162
163
164
165
# File 'app/services/assistant/chat_tool_builder.rb', line 159

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) ⇒ Object

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".



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'app/services/assistant/chat_tool_builder.rb', line 258

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
  (sticky.uniq & keys)
end

.tool_service_keys_for_step(step_description, permitted_keys, sticky_keys: []) ⇒ Object



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
# File 'app/services/assistant/chat_tool_builder.rb', line 278

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!
  return keys if matched.empty?

  intersection = matched & keys
  result = intersection.presence || keys

  # 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+



350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'app/services/assistant/chat_tool_builder.rb', line 350

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

  # 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_cache_breakpoint(tools.last) if provider == :anthropic && tools.any?

  tools
end

Instance Method Details

#build_gamma_toolsObject



863
864
865
# File 'app/services/assistant/chat_tool_builder.rb', line 863

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

#build_mcp_tools(tool_names) ⇒ Object



882
883
884
885
886
887
# File 'app/services/assistant/chat_tool_builder.rb', line 882

public def build_mcp_tools(tool_names)
  ensure_mcp_tools_loaded!
  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!Object



889
890
891
892
893
# File 'app/services/assistant/chat_tool_builder.rb', line 889

public def ensure_mcp_tools_loaded!
  return if ApplicationTool.descendants.any?

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

#gamma_toolsObject

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



859
860
861
# File 'app/services/assistant/chat_tool_builder.rb', line 859

public def gamma_tools
  @gamma_tools ||= build_gamma_tools
end

#normalize_schema(raw_schema) ⇒ Object

Normalize MCP input_schema to RubyLLM-compatible JSON Schema



852
853
854
855
# File 'app/services/assistant/chat_tool_builder.rb', line 852

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

#safe_build_tools(label) ⇒ Object

─── Shared Helpers ───────────────────────────────────────────────



875
876
877
878
879
880
# File 'app/services/assistant/chat_tool_builder.rb', line 875

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

#sales_management_toolsObject

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



869
870
871
# File 'app/services/assistant/chat_tool_builder.rb', line 869

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) ⇒ Object

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.



763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
# File 'app/services/assistant/chat_tool_builder.rb', line 763

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)
end