Class: RailsAiContext::Tools::BaseTool
- Inherits:
-
MCP::Tool
- Object
- MCP::Tool
- RailsAiContext::Tools::BaseTool
- Extended by:
- CountPhrase, SectionFetch
- Defined in:
- lib/rails_ai_context/tools/base_tool.rb
Overview
Base class for all MCP tools exposed by rails-ai-context. Inherits from the official MCP::Tool to get schema validation, annotations, and protocol compliance for free.
Direct Known Subclasses
AnalyzeFeature, DependencyGraph, Diagnose, GenerateTest, GetActiveSupport, GetApi, GetAutoload, GetCallbacks, GetComponentCatalog, GetConcern, GetConfig, GetContext, GetControllers, GetConventions, GetEditContext, GetEngines, GetEnv, GetEnvConfig, GetFrontendStack, GetGems, GetHelperMethods, GetI18n, GetJobPattern, GetMailers, GetModelDetails, GetPartialInterface, GetRoutes, GetSchema, GetServicePattern, GetStimulus, GetTestInfo, GetTurboMap, GetView, MigrationAdvisor, Onboard, PerformanceCheck, Query, ReadLogs, ReviewChanges, RuntimeInfo, SearchCode, SearchDocs, SecurityScan, SessionContext, Validate, ValidateSemantics
Defined Under Namespace
Classes: GuideRow
Constant Summary collapse
- SHARED_CACHE =
Shared cache across all tool subclasses, protected by a Mutex for thread safety in multi-threaded servers (e.g., Puma).
{ mutex: Mutex.new }
- SESSION_CONTEXT =
Session-level context tracking. Lets AI avoid redundant queries by recording what tools have been called with what params. In-memory only - resets on server restart.
Bucketed per conversation. Over stdio one process serves one conversation and everything lands in DEFAULT_SESSION; the HTTP transports serve many from one process, so each request's Mcp-Session-Id gets its own bucket and one client's history stays out of another's. A plain Hash, not one with a default block: a default block writes on lookup, so merely reading a session's history created it.
{ mutex: Mutex.new, queries: {} }
- DEFAULT_SESSION =
:default- MAX_SESSIONS =
The session id comes from a client-controlled header in a process that stays up, so both its length and the number of them are capped. Oldest-first eviction: a conversation nobody has touched in the last MAX_SESSIONS is the one least likely to ask about its own history.
100- MAX_SESSION_ID_LENGTH =
200
Class Attribute Summary collapse
-
.descendants ⇒ Object
readonly
Returns the value of attribute descendants.
-
.registry_mutex ⇒ Object
readonly
Returns the value of attribute registry_mutex.
Class Method Summary collapse
-
.abstract! ⇒ Object
Mark a tool class as abstract (excluded from registration).
- .abstract? ⇒ Boolean
-
.api_only_app? ⇒ Boolean
API-only apps legitimately have no views, partials, Stimulus, or Turbo surface; a bare empty listing is indistinguishable from a full-stack app that has none yet, so name the reason.
-
.api_only_note(section_label) ⇒ Object
Short honest line for a view/frontend section that doesn't apply on an API-only app, or nil when the app has a view layer.
-
.cache_key ⇒ Object
Cache key for paginated responses - lets agents detect stale data between pages.
-
.cached_context ⇒ Object
Cache introspection results with TTL + fingerprint invalidation.
- .config ⇒ Object
- .current_session ⇒ Object
-
.detail_param? ⇒ Boolean
Whether this tool's
detailis DetailLevel's, read off the schema it already publishes rather than a second list someone has to keep in step. -
.error_response(text) ⇒ Object
Helper: wrap text in an MCP::Tool::Response flagged as an error (isError: true) so MCP clients and the CLI treat the call as failed (non-zero exit).
-
.evict_oldest_sessions ⇒ Object
The front of the hash is now the least recently used session.
-
.extract_method_source_from_file(path, method_name) ⇒ Object
Extract method source from a file path.
-
.extract_method_source_from_string(source, method_name) ⇒ Object
Extract method source from a source string via indentation-based matching.
-
.find_closest_match(input, available) ⇒ Object
Fuzzy match: find the closest available name by exact, underscore, substring, or prefix.
-
.fuzzy_find_key(keys, query) ⇒ Object
Case-insensitive fuzzy key lookup for hashes keyed by class/table names.
- .guide_row(order: nil, mcp: nil, cli_args: nil, summary: nil) ⇒ Object
- .inherited(subclass) ⇒ Object
-
.introspection_warnings_note(ctx) ⇒ Object
One-line banner listing introspectors that failed during context generation.
-
.invalid_detail_note(given) ⇒ Object
Normalizing silently would answer a question the caller did not ask and give them no way to notice.
-
.normalize_detail(kwargs) ⇒ Object
Junk and missing values both become the default here, so the nineteen
case detailbranches downstream only ever see one of three strings. -
.not_found_response(type, name, available, recovery_tool: nil) ⇒ Object
Structured not-found error with fuzzy suggestion and recovery hint.
-
.paginate(items, offset:, limit:, default_limit: 50) ⇒ Object
Standardized pagination: slice items with offset/limit and produce a consistent hint.
-
.rails_app ⇒ Object
Convenience: access the Rails app and cached introspection.
-
.rails_env_name ⇒ Object
The current environment name without requiring a booted app: Rails.env when a real Rails is loaded and responds to it, the ambient RAILS_ENV otherwise.
-
.registered_tools ⇒ Object
All non-abstract tool classes.
-
.reset_all_caches! ⇒ Object
Reset the shared cache.
- .reset_cache! ⇒ Object
-
.session_from(env) ⇒ Object
Which conversation a request belongs to, read from the Rack env all three HTTP entry points already hold.
-
.session_params(kwargs) ⇒ Object
What the session record should remember about this call.
-
.session_queries ⇒ Object
Deep copies: the entries stay live inside the record and keep being mutated by later calls, so handing the originals out would let a caller's snapshot change under it.
- .session_record(tool_name, params, summary = nil) ⇒ Object
- .session_reset! ⇒ Object
-
.static_tier_banner ⇒ Object
One banner per response in static tier: consumers must never mistake static analysis for runtime-confirmed data.
-
.static_tier_refusal(capability) ⇒ Object
Tools that only make sense against a booted app must refuse in the static tier instead of half-running against whatever a failed boot happened to load (live DB access from a "static" response contradicts the tier banner in the same reply).
-
.text_response(text, suffix: nil) ⇒ Object
Helper: wrap text in an MCP::Tool::Response with safety-net truncation.
-
.touch_session ⇒ Object
Re-inserting moves this session to the back, so hash order is least-recently-used rather than oldest-created.
-
.unavailable_note(section_data) ⇒ Object
Short honest line for a context section that could not be produced because the app isn't booted, as opposed to one that ran and found nothing.
-
.with_session(session_id) ⇒ Object
Run a block against one conversation's session record.
-
.with_session_for(env, &block) ⇒ Object
One process serves every client on all three HTTP entry points, so a request must run scoped to whoever sent it or the session record pools conversations together.
Instance Method Summary collapse
-
#dedupe_put_patch_routes(actions) ⇒ Object
Merge duplicate PUT/PATCH entries for the same path+action into a single "PATCH|PUT" entry (Rails generates both for every
resourcesupdate route).
Methods included from SectionFetch
Methods included from CountPhrase
Class Attribute Details
.descendants ⇒ Object (readonly)
Returns the value of attribute descendants.
31 32 33 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 31 def descendants @descendants end |
.registry_mutex ⇒ Object (readonly)
Returns the value of attribute registry_mutex.
31 32 33 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 31 def registry_mutex @registry_mutex end |
Class Method Details
.abstract! ⇒ Object
Mark a tool class as abstract (excluded from registration). Reaches back to BaseTool explicitly: registry_mutex/descendants are ivars on the BaseTool object, and a subclass calling this method has no ivar storage of its own to read them from.
37 38 39 40 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 37 def abstract! @abstract = true BaseTool.registry_mutex.synchronize { BaseTool.descendants.delete(self) } end |
.abstract? ⇒ Boolean
42 43 44 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 42 def abstract? @abstract == true end |
.api_only_app? ⇒ Boolean
API-only apps legitimately have no views, partials, Stimulus, or Turbo surface; a bare empty listing is indistinguishable from a full-stack app that has none yet, so name the reason.
353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 353 def api_only_app? api = cached_context[:api] return api[:api_only] == true if api.is_a?(Hash) && api.key?(:api_only) app = rails_app if app.respond_to?(:config) && app.config.respond_to?(:api_only) return app.config.api_only == true end # Static tier: no booted config to ask, but the flag is declared in # config/application.rb. Without this the view tools fall back to # "none found", which reads as "not built yet" for an app that has # no view layer by design. AppKind.api_only?(app.root) rescue StandardError false end |
.api_only_note(section_label) ⇒ Object
Short honest line for a view/frontend section that doesn't apply on an API-only app, or nil when the app has a view layer. Tools check this before rendering "no X found" copy so a legitimately absent surface never reads as "not built yet".
375 376 377 378 379 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 375 def api_only_note(section_label) return nil unless api_only_app? "Not applicable: this is an API-only app (config.api_only), so #{section_label} does not exist." end |
.cache_key ⇒ Object
Cache key for paginated responses - lets agents detect stale data between pages
441 442 443 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 441 def cache_key SHARED_CACHE[:fingerprint] || "none" end |
.cached_context ⇒ Object
Cache introspection results with TTL + fingerprint invalidation. Uses SHARED_CACHE so all tool subclasses share one introspection result instead of each caching independently.
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 156 def cached_context SHARED_CACHE[:mutex].synchronize do now = Process.clock_gettime(Process::CLOCK_MONOTONIC) ttl = RailsAiContext.configuration.cache_ttl # Fast path: within TTL window, trust the cache and skip the # fingerprint walk entirely. Fingerprinter stats every *.rb file # in WATCHED_DIRS (plus, in dev-mode path: installs, every file # in the gem's own lib/ tree) - measured at ~12ms per call in # dev mode, ~0.5ms in production. Since LiveReload fires # reset_all_caches! on actual file-change events, stale-cache # risk during a short TTL window is already covered. if SHARED_CACHE[:context] && (now - SHARED_CACHE[:timestamp]) < ttl return SHARED_CACHE[:context].deep_dup end # TTL expired: re-validate via fingerprint before re-introspecting. # If fingerprint is unchanged, bump the timestamp and reuse the # cached context - saves re-running all 40 introspectors. if SHARED_CACHE[:context] && !Fingerprinter.changed?(rails_app, SHARED_CACHE[:fingerprint]) SHARED_CACHE[:timestamp] = now return SHARED_CACHE[:context].deep_dup end SHARED_CACHE[:context] = RailsAiContext.introspect SHARED_CACHE[:timestamp] = now SHARED_CACHE[:fingerprint] = Fingerprinter.compute(rails_app) SHARED_CACHE[:context].deep_dup end end |
.config ⇒ Object
139 140 141 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 139 def config RailsAiContext.configuration end |
.current_session ⇒ Object
218 219 220 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 218 def current_session Thread.current[:rails_ai_context_session] || DEFAULT_SESSION end |
.detail_param? ⇒ Boolean
Whether this tool's detail is DetailLevel's, read off the schema it
already publishes rather than a second list someone has to keep in
step. rails_onboard spells its own levels (quick/standard/full) and
is deliberately not covered.
512 513 514 515 516 517 518 519 520 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 512 def detail_param? return @detail_param if defined?(@detail_param) properties = (respond_to?(:input_schema) ? input_schema&.to_h : nil)&.dig(:properties) declared = properties.is_a?(Hash) ? (properties[:detail] || properties["detail"]) : nil enum = declared.is_a?(Hash) ? (declared[:enum] || declared["enum"]) : nil @detail_param = Array(enum).map(&:to_s) == RailsAiContext::DetailLevel::ALL end |
.error_response(text) ⇒ Object
Helper: wrap text in an MCP::Tool::Response flagged as an error (isError: true) so MCP clients and the CLI treat the call as failed (non-zero exit). Mirrors the SafeCall rescue wrapper. Use for genuine execution failures only - policy blocks and guidance messages stay informational via text_response.
581 582 583 584 585 586 587 588 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 581 def error_response(text) # A failed call must not leak its recorded params into the next # call's session entry. Thread.current[:rails_ai_context_call_params] = nil = text += if MCP::Tool::Response.new([ { type: "text", text: text } ], error: true) end |
.evict_oldest_sessions ⇒ Object
The front of the hash is now the least recently used session. Called with the mutex held.
288 289 290 291 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 288 def evict_oldest_sessions queries = SESSION_CONTEXT[:queries] queries.shift while queries.size > MAX_SESSIONS end |
.extract_method_source_from_file(path, method_name) ⇒ Object
Extract method source from a file path. Reads file safely. Returns hash or nil.
492 493 494 495 496 497 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 492 def extract_method_source_from_file(path, method_name) return nil unless File.exist?(path) return nil if File.size(path) > RailsAiContext.configuration.max_file_size source = RailsAiContext::SafeFile.read(path) || "" extract_method_source_from_string(source, method_name) end |
.extract_method_source_from_string(source, method_name) ⇒ Object
Extract method source from a source string via indentation-based matching. Returns { code:, start_line:, end_line: } or nil. Shared by get_callbacks, get_concern.
463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 463 def extract_method_source_from_string(source, method_name) source_lines = source.lines escaped = Regexp.escape(method_name.to_s) # ? and ! ARE word boundaries, so skip \b after them pattern = if method_name.to_s.end_with?("?", "!") /\A\s*def\s+#{escaped}/ else /\A\s*def\s+#{escaped}\b/ end start_idx = source_lines.index { |l| l.match?(pattern) } return nil unless start_idx def_indent = source_lines[start_idx][/\A\s*/].length result = [] end_idx = start_idx source_lines[start_idx..].each_with_index do |line, i| result << line.rstrip end_idx = start_idx + i break if i > 0 && line.match?(/\A\s{#{def_indent}}end\b/) end { code: result.join("\n"), start_line: start_idx + 1, end_line: end_idx + 1 } rescue => e $stderr.puts "[rails-ai-context] extract_method_source_from_string failed: #{e.}" if ENV["DEBUG"] nil end |
.find_closest_match(input, available) ⇒ Object
Fuzzy match: find the closest available name by exact, underscore, substring, or prefix
415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 415 def find_closest_match(input, available) return nil if available.empty? # A blank query matches everything via substring ("".include? anything), # so it would otherwise surface an arbitrary "Did you mean" suggestion # for input that isn't a typo at all - just missing. return nil if input.to_s.strip.empty? downcased = input.downcase underscored = input.underscore.downcase # Exact case-insensitive match (including underscore/classify variants) exact = available.find do |a| a_down = a.downcase a_under = a.underscore.downcase a_down == downcased || a_under == underscored || a_down == underscored || a_under == downcased end return exact if exact # Substring match - prefer shortest (most specific) to avoid post → post_comments substring_matches = available.select { |a| a.downcase.include?(downcased) || downcased.include?(a.downcase) } return substring_matches.min_by(&:length) if substring_matches.any? # Prefix match available.find { |a| a.downcase.start_with?(downcased[0..2]) } end |
.fuzzy_find_key(keys, query) ⇒ Object
Case-insensitive fuzzy key lookup for hashes keyed by class/table names. Tries exact, underscore, singularize, and classify variants. Returns matching key or nil. Shared by get_model_details, get_callbacks, get_context, generate_test, dependency_graph.
448 449 450 451 452 453 454 455 456 457 458 459 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 448 def fuzzy_find_key(keys, query) return nil if query.nil? || keys.nil? || keys.empty? q = query.to_s.strip return nil if q.empty? q_down = q.downcase q_under = q.underscore.downcase keys.find { |k| k.to_s.downcase == q_down } || keys.find { |k| k.to_s.underscore.downcase == q_under } || keys.find { |k| k.to_s.downcase == q.singularize.downcase } || keys.find { |k| k.to_s.downcase == q.classify.downcase } end |
.guide_row(order: nil, mcp: nil, cli_args: nil, summary: nil) ⇒ Object
124 125 126 127 128 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 124 def guide_row(order: nil, mcp: nil, cli_args: nil, summary: nil) return @guide_row if order.nil? @guide_row = GuideRow.new(order: order, mcp: mcp, cli_args: cli_args, summary: summary) end |
.inherited(subclass) ⇒ Object
19 20 21 22 23 24 25 26 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 19 def self.inherited(subclass) super subclass.instance_variable_set(:@abstract, false) subclass.singleton_class.prepend(SafeCall) # Thread-safe append. Mutex is NOT held during eager_load!'s const_get # (which triggers inherited), so no recursive locking risk here. BaseTool.registry_mutex.synchronize { BaseTool.descendants << subclass } end |
.introspection_warnings_note(ctx) ⇒ Object
One-line banner listing introspectors that failed during context generation. Aggregate tools append this so AI clients know which sections are missing rather than empty.
329 330 331 332 333 334 335 336 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 329 def introspection_warnings_note(ctx) warnings = ctx.is_a?(Hash) ? ctx[:_warnings] : nil return nil unless warnings.is_a?(Array) && warnings.any? failed = warnings.map { |w| w[:introspector] }.compact.join(", ") "\n\n---\n_Partial context: introspection failed for #{failed}. " \ "Data from those sections is missing, not empty._" end |
.invalid_detail_note(given) ⇒ Object
Normalizing silently would answer a question the caller did not ask and give them no way to notice. SafeCall appends this after the response is built, so it lands past truncation the way the static-tier banner does.
The echoed value is shortened: it is caller input, and the response it rides on has just promised a length cap.
538 539 540 541 542 543 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 538 def invalid_detail_note(given) return nil unless given "\n\n---\n_#{given.to_s.truncate(40).inspect} is not a valid `detail`; showing " \ "#{RailsAiContext::DetailLevel::DEFAULT}. Valid: #{RailsAiContext::DetailLevel::ALL.join(', ')}._" end |
.normalize_detail(kwargs) ⇒ Object
Junk and missing values both become the default here, so the
nineteen case detail branches downstream only ever see one of
three strings.
525 526 527 528 529 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 525 def normalize_detail(kwargs) return kwargs unless detail_param? kwargs.merge(detail: RailsAiContext::DetailLevel.normalize(kwargs[:detail])) end |
.not_found_response(type, name, available, recovery_tool: nil) ⇒ Object
Structured not-found error with fuzzy suggestion and recovery hint. Helps AI agents self-correct without retrying blind.
315 316 317 318 319 320 321 322 323 324 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 315 def not_found_response(type, name, available, recovery_tool: nil) suggestion = find_closest_match(name, available) # Don't suggest the exact same string the user typed - that's useless suggestion = nil if suggestion == name lines = [ "#{type} '#{name}' not found." ] lines << "Did you mean '#{suggestion}'?" if suggestion lines << "Available: #{available.first(20).join(', ')}#{"..." if available.size > 20}" if available.any? lines << "_Recovery: #{recovery_tool}_" if recovery_tool text_response(lines.join("\n")) end |
.paginate(items, offset:, limit:, default_limit: 50) ⇒ Object
Standardized pagination: slice items with offset/limit and produce a consistent hint. Returns { items:, hint:, total:, offset:, limit: }
296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 296 def paginate(items, offset:, limit:, default_limit: 50) offset = [ offset.to_i, 0 ].max limit = limit.nil? ? default_limit : [ limit.to_i, 1 ].max total = items.size sliced = items.drop(offset).first(limit) hint = if sliced.empty? && total > 0 "_No items at offset #{offset}. Total: #{total}._" elsif offset + limit < total "_Showing #{offset + 1}-#{offset + sliced.size} of #{total}. Use offset:#{offset + limit} for next page._" else "" end { items: sliced, hint: hint, total: total, offset: offset, limit: limit } end |
.rails_app ⇒ Object
Convenience: access the Rails app and cached introspection. Routes through RailsAiContext.default_app so this resolves to the booted app in runtime tier and to a StaticApp in static tier - tools that call rails_app directly (get_concern, analyze_feature, migration_advisor, ...) work in both tiers without their own checks.
135 136 137 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 135 def rails_app RailsAiContext.default_app end |
.rails_env_name ⇒ Object
The current environment name without requiring a booted app:
Rails.env when a real Rails is loaded and responds to it, the
ambient RAILS_ENV otherwise. Tools that only need the environment
name (not the full StringInquirer API) use this instead of a bare
Rails.env reference, which would NameError under --no-boot or
early boot death.
149 150 151 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 149 def rails_env_name defined?(Rails) && Rails.respond_to?(:env) ? Rails.env : (ENV["RAILS_ENV"] || "development") end |
.registered_tools ⇒ Object
All non-abstract tool classes. Triggers eager loading first.
47 48 49 50 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 47 def registered_tools eager_load! registry_mutex.synchronize { descendants.reject(&:abstract?) } end |
.reset_all_caches! ⇒ Object
Reset the shared cache. Used by LiveReload to invalidate on file change.
200 201 202 203 204 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 200 def reset_all_caches! reset_cache! session_reset! AstCache.clear end |
.reset_cache! ⇒ Object
187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 187 def reset_cache! SHARED_CACHE[:mutex].synchronize do SHARED_CACHE.delete(:context) SHARED_CACHE.delete(:timestamp) SHARED_CACHE.delete(:fingerprint) end # Also invalidate the memoized gem-lib fingerprint so active gem # development sees a fresh scan on next call without a process # restart. No-op for production installs. Fingerprinter.reset_gem_lib_fingerprint! end |
.session_from(env) ⇒ Object
Which conversation a request belongs to, read from the Rack env all
three HTTP entry points already hold. The engine controller reaches
it through request.env rather than naming the header a third time.
225 226 227 228 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 225 def session_from(env) id = env["HTTP_MCP_SESSION_ID"] id.nil? || id.empty? ? DEFAULT_SESSION : id[0, MAX_SESSION_ID_LENGTH] end |
.session_params(kwargs) ⇒ Object
What the session record should remember about this call. SafeCall asks every tool, so no tool has to remember to record anything; override to reshape a value that should not be kept verbatim.
502 503 504 505 506 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 502 def session_params(kwargs) kwargs .except(:server_context) .reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) } end |
.session_queries ⇒ Object
Deep copies: the entries stay live inside the record and keep being mutated by later calls, so handing the originals out would let a caller's snapshot change under it.
263 264 265 266 267 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 263 def session_queries SESSION_CONTEXT[:mutex].synchronize do (SESSION_CONTEXT[:queries][current_session] || {}).values.map(&:dup) end end |
.session_record(tool_name, params, summary = nil) ⇒ Object
238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 238 def session_record(tool_name, params, summary = nil) SESSION_CONTEXT[:mutex].synchronize do bucket = touch_session evict_oldest_sessions key = session_key(tool_name, params) existing = bucket[key] if existing existing[:call_count] = (existing[:call_count] || 1) + 1 existing[:last_timestamp] = Time.now.iso8601 existing[:summary] = summary if summary else bucket[key] = { tool: tool_name.to_s, params: params, call_count: 1, timestamp: Time.now.iso8601, summary: summary } end end end |
.session_reset! ⇒ Object
269 270 271 272 273 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 269 def session_reset! SESSION_CONTEXT[:mutex].synchronize do SESSION_CONTEXT[:queries].clear end end |
.static_tier_banner ⇒ Object
One banner per response in static tier: consumers must never mistake static analysis for runtime-confirmed data. Rides the suffix mechanism so it survives truncation.
384 385 386 387 388 389 390 391 392 393 394 395 396 397 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 384 def return nil unless RailsAiContext.static_tier? reason = RailsAiContext.static_reason headline = if reason.to_s.include?("--no-boot") "Static mode (#{reason})" elsif reason "App boot failed (#{reason})" else "Static mode" end "\n\n---\n_[STATIC] #{headline}. Serving static analysis; runtime-only data is marked " \ "[UNAVAILABLE]. Run `rails-ai-context doctor` for details._" end |
.static_tier_refusal(capability) ⇒ Object
Tools that only make sense against a booted app must refuse in the static tier instead of half-running against whatever a failed boot happened to load (live DB access from a "static" response contradicts the tier banner in the same reply).
403 404 405 406 407 408 409 410 411 412 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 403 def static_tier_refusal(capability) return nil unless RailsAiContext.static_tier? reason = RailsAiContext.static_reason text_response( "[UNAVAILABLE: static tier] #{capability} requires a booted Rails app" \ "#{reason ? " (static tier active: #{reason})" : ""}. " \ "Fix the boot failure (see `rails-ai-context doctor`) or rerun without `--no-boot`." ) end |
.text_response(text, suffix: nil) ⇒ Object
Helper: wrap text in an MCP::Tool::Response with safety-net truncation.
Auto-records the call in session context so session_context(action:"status") works.
suffix:, when given, is appended after the truncation footer (or after
the text itself when untruncated) so callers can attach a short trailing
note that must survive truncation instead of being cut off with the tail.
In static tier, the tier banner rides along on the same mechanism so
every response - caller-suffixed or not - ends with it.
552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 552 def text_response(text, suffix: nil) suffix = [ suffix, ].compact.join suffix = nil if suffix.empty? # Auto-track: record this tool call in session context (skip SessionContext itself to avoid recursion) if respond_to?(:tool_name) && tool_name != "rails_session_context" summary = text.lines.first&.strip&.truncate(80) params = Thread.current[:rails_ai_context_call_params] || {} session_record(tool_name, params, summary) Thread.current[:rails_ai_context_call_params] = nil end max = RailsAiContext.configuration.max_tool_response_chars if max && text.length > max truncated = text[0...max] truncated += "\n\n---\n_Response truncated (#{text.length} chars). Use `detail:\"summary\"` for an overview, or filter by a specific item (e.g. `table:\"users\"`)._" truncated += suffix if suffix MCP::Tool::Response.new([ { type: "text", text: truncated } ]) else text += suffix if suffix MCP::Tool::Response.new([ { type: "text", text: text } ]) end end |
.touch_session ⇒ Object
Re-inserting moves this session to the back, so hash order is least-recently-used rather than oldest-created. Without it a conversation that has run for hours is evicted ahead of a hundred idle newcomers - backwards, and worst on the long-lived transports that made bucketing necessary. Called with the mutex held.
280 281 282 283 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 280 def touch_session queries = SESSION_CONTEXT[:queries] queries[current_session] = queries.delete(current_session) || {} end |
.unavailable_note(section_data) ⇒ Object
Short honest line for a context section that could not be produced because the app isn't booted, as opposed to one that ran and found nothing. Tools check this before rendering "not found"/empty copy so a missing runtime capability never reads as a confirmed negative (e.g. "No notable gems found" when gems were never inspected at all).
344 345 346 347 348 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 344 def unavailable_note(section_data) return nil unless section_data.is_a?(Hash) && section_data[:unavailable] "[UNAVAILABLE: #{section_data[:unavailable]}]" end |
.with_session(session_id) ⇒ Object
Run a block against one conversation's session record. The HTTP transports wrap each request in this; stdio never calls it.
210 211 212 213 214 215 216 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 210 def with_session(session_id) previous = Thread.current[:rails_ai_context_session] Thread.current[:rails_ai_context_session] = session_id yield ensure Thread.current[:rails_ai_context_session] = previous end |
.with_session_for(env, &block) ⇒ Object
One process serves every client on all three HTTP entry points, so a request must run scoped to whoever sent it or the session record pools conversations together. See docs/adr/0003-shared-tool-cache-semantics.md.
234 235 236 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 234 def with_session_for(env, &block) with_session(session_from(env), &block) end |
Instance Method Details
#dedupe_put_patch_routes(actions) ⇒ Object
Merge duplicate PUT/PATCH entries for the same path+action into a
single "PATCH|PUT" entry (Rails generates both for every resources
update route). Public: the VFS routes resource uses it too, so route
counts stay consistent across every surface that reports them.
669 670 671 672 673 674 675 676 677 678 679 680 |
# File 'lib/rails_ai_context/tools/base_tool.rb', line 669 public def dedupe_put_patch_routes(actions) deduped = [] actions.each do |r| existing = deduped.find { |d| d[:path] == r[:path] && d[:action] == r[:action] } if existing && %w[PUT PATCH].include?(r[:verb]) && %w[PUT PATCH].include?(existing[:verb]) existing[:verb] = "PATCH|PUT" else deduped << r.dup end end deduped end |