Class: RailsAiContext::Tools::BaseTool

Inherits:
MCP::Tool
  • Object
show all
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.

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

Class Method Summary collapse

Instance Method Summary collapse

Methods included from SectionFetch

fetch_section, usable?

Methods included from CountPhrase

call

Class Attribute Details

.descendantsObject (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_mutexObject (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

Returns:

  • (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.

Returns:

  • (Boolean)


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()
  return nil unless api_only_app?

  "Not applicable: this is an API-only app (config.api_only), so #{} does not exist."
end

.cache_keyObject

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_contextObject

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

.configObject



139
140
141
# File 'lib/rails_ai_context/tools/base_tool.rb', line 139

def config
  RailsAiContext.configuration
end

.current_sessionObject



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.

Returns:

  • (Boolean)


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
  banner = static_tier_banner
  text += banner if banner
  MCP::Tool::Response.new([ { type: "text", text: text } ], error: true)
end

.evict_oldest_sessionsObject

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.message}" 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_appObject

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_nameObject

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_toolsObject

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_queriesObject

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_bannerObject

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 static_tier_banner
  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, static_tier_banner ].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_sessionObject

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