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- 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 thetypesparameter 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. [ [/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
-
.chat_services ⇒ Object
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 ⇒ Object
-
.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) ⇒ Object
- .tool_service_keys_for_step(step_description, permitted_keys, sticky_keys: []) ⇒ Object
-
.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 ⇒ Object
- #build_mcp_tools(tool_names) ⇒ Object
- #ensure_mcp_tools_loaded! ⇒ Object
-
#gamma_tools ⇒ Object
─── Gamma Tools ─────────────────────────────────────────────────.
-
#normalize_schema(raw_schema) ⇒ Object
Normalize MCP input_schema to RubyLLM-compatible JSON Schema.
-
#safe_build_tools(label) ⇒ Object
─── Shared Helpers ───────────────────────────────────────────────.
-
#sales_management_tools ⇒ Object
─── Sales Management Tools ──────────────────────────────────────.
-
#truncate_result(result_str, max_chars: MAX_TOOL_RESULT_CHARS) ⇒ Object
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 ⇒ Object
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.
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(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)
332 333 334 335 336 337 338 339 340 |
# File 'app/services/assistant/chat_tool_builder.rb', line 332 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.
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).
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_handle ⇒ Object
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, ), 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.
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
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+.
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: 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_tools ⇒ Object
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_tools ⇒ Object
─── 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.}") [] end |
#sales_management_tools ⇒ Object
─── 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 |