debug-agent 0.2.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 282c047d0e86a70412110ecbd1a3579ee8a71af4b2743de37a4769242df6b634
4
- data.tar.gz: ba97cf4ae8a5757f70c5bf261e5b5b34fb5d752a5543e9b4c657db5fdfc299bf
3
+ metadata.gz: 791b8f21395b061038d65dcae92007460556766622991c1c0a1692ae21b272f5
4
+ data.tar.gz: b57b2b97b9473da04c7b7fc3cf67729bddfc283e2ca71d34b4e6469d2c47e65a
5
5
  SHA512:
6
- metadata.gz: 0b68399b6dd5fe9d83f9369a9ab896bde2c2d6941a3d8d531b6721427f21b96643e9e8b3793a7da31984b34da7ed79779ba43a30bcbf57e6c20b8451831482f0
7
- data.tar.gz: c4dc62c89f4bfd0100dae01b636130cc0316b5bc1f2981db85d52a8e360a997bf755851187719be8a88f4e05faf443bbc7c535d83b29011ba4c4b2fd8582cb89
6
+ metadata.gz: 7fd90445975ede4bbc7474c7a57e94e1914098d9c4743180fdcdb737611ef461f2a6034da40fd31eb101188c34a28a12ac056db119f510e3de37ac494e06b67b
7
+ data.tar.gz: acf3c742b54b5d8173e6908ec27e079cd66efd4063f515b9278ec8754ef84e1539bbe5e13d47c79d342c33046678b972b9c6873cb6c5c9c96618669209145fa2
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Ruby Debug Agent
2
2
 
3
- An AI-powered runtime debugging agent that embeds directly into your Ruby application. Add one gem, configure an LLM key, and chat with your live app at `/agent` to inspect GC, ObjectSpace, threads, routes, process info, HTTP requests, and more.
3
+ [![Gem Version](https://img.shields.io/badge/gem-debug--agent-red)](https://github.com/topcheer/ruby-debug-agent)
4
+ ![Tools](https://img.shields.io/badge/tools-54-blue)
5
+ ![Inspectors](https://img.shields.io/badge/inspectors-20-green)
6
+
7
+ An AI-powered runtime debugging agent that embeds directly into your Ruby application. Add one gem, configure an LLM key, and chat with your live app at `/agent` to inspect GC, ObjectSpace, threads, routes, Redis, Rails models/routes, Sidekiq queues, Puma stats, fibers/signals, process info, HTTP requests, and more — **54 diagnostic tools across 20 inspectors**.
4
8
 
5
9
  ## Quick Start
6
10
 
@@ -51,9 +55,10 @@ http://localhost:4567/agent
51
55
  - **Context compression** — automatically summarizes old conversation when token limit is approached
52
56
  - **Dark-themed chat UI** with full markdown rendering (tables, code blocks, lists)
53
57
  - **Max tool rounds** (25) with forced final summary when limit is reached
54
- - **26 diagnostic tools** across 8 inspectors
58
+ - **54 diagnostic tools** across **20 inspectors**
59
+ - Zero external dependencies (no Datadog, no Grafana, no APM)
55
60
 
56
- ## Inspectors & Tools (26)
61
+ ## Inspectors & Tools (54)
57
62
 
58
63
  ### GC Inspector
59
64
  | Tool | Description |
@@ -75,6 +80,7 @@ http://localhost:4567/agent
75
80
  | `get_thread_list` | List all threads with status and backtrace summary |
76
81
  | `get_thread_count` | Thread count |
77
82
  | `get_main_thread_info` | Main thread priority, status |
83
+ | `get_thread_backtrace` | Full backtrace for a specific thread |
78
84
 
79
85
  ### Route Inspector
80
86
  | Tool | Description |
@@ -88,6 +94,7 @@ http://localhost:4567/agent
88
94
  | `get_process_info` | PID, ppid, platform, Ruby version, uptime |
89
95
  | `get_cpu_time` | Process.times() user/sys CPU time |
90
96
  | `get_environment_variables` | Environment variables (masked secrets) |
97
+ | `get_process_memory` | Process RSS, VMS, and memory growth trend |
91
98
 
92
99
  ### Runtime Inspector
93
100
  | Tool | Description |
@@ -108,9 +115,84 @@ http://localhost:4567/agent
108
115
  | Tool | Description |
109
116
  |------|-------------|
110
117
  | `get_system_info` | Hostname, CPU cores, disk |
111
- | `get_disk_usage` | Disk usage for working directory |
118
+ | `get_disk_usage` | Disk usage for the working directory |
112
119
  | `get_file_descriptors` | Open file descriptor count |
113
120
 
121
+ ### Redis Inspector
122
+ | Tool | Description |
123
+ |------|-------------|
124
+ | `get_redis_info` | Redis server info: memory, clients, persistence |
125
+ | `get_redis_keys` | Scan Redis keyspace with pattern matching |
126
+ | `get_redis_slowlog` | Redis slow query log entries |
127
+ | `get_redis_stats` | Per-command call count, hit/miss ratio, keyspace stats |
128
+
129
+ ### Rails Inspector
130
+ | Tool | Description |
131
+ |------|-------------|
132
+ | `get_rails_models` | List ActiveRecord models with table names and associations |
133
+ | `get_rails_routes` | List Rails routes with helper names and HTTP verbs |
134
+ | `get_rails_db_schema` | Database schema version and pending migrations |
135
+
136
+ ### Sidekiq Inspector
137
+ | Tool | Description |
138
+ |------|-------------|
139
+ | `get_sidekiq_queues` | Queue list with depth, latency, and size |
140
+ | `get_sidekiq_workers` | Active Sidekiq workers with job and host info |
141
+ | `get_sidekiq_jobs` | Inspect jobs in a queue/retry set with payload |
142
+
143
+ ### Puma Inspector
144
+ | Tool | Description |
145
+ |------|-------------|
146
+ | `get_puma_stats` | Puma cluster stats: workers, threads, running/backlog, boot time |
147
+
148
+ ### Fibers/Signals Inspector
149
+ | Tool | Description |
150
+ |------|-------------|
151
+ | `get_fiber_list` | List active Ruby Fibers with state and backtrace |
152
+ | `get_signal_handlers` | List registered signal handlers (Signal.trap) |
153
+ | `get_trap_handlers` | Inspect trap handlers for SIGINT, SIGTERM, etc. |
154
+
155
+ ### Logging Inspector
156
+ | Tool | Description |
157
+ |------|-------------|
158
+ | `get_log_buffer` | Recent log entries from the built-in ring buffer |
159
+ | `get_logger_info` | Current log level and configuration for registered loggers |
160
+ | `set_log_level` | Dynamically set the log level for a registered logger |
161
+
162
+ ### Cache Inspector
163
+ | Tool | Description |
164
+ |------|-------------|
165
+ | `get_cache_stats` | Stats for registered caches (hit rate, miss count, key count) |
166
+ | `get_cache_keys` | List keys from a registered cache with optional prefix filter |
167
+ | `clear_cache` | Clear all entries from a registered cache |
168
+
169
+ ### Outbound HTTP Inspector
170
+ | Tool | Description |
171
+ |------|-------------|
172
+ | `get_http_connections` | HTTP client connection stats (Net::HTTP, connection pool) |
173
+ | `get_outbound_summary` | Aggregated outbound HTTP call stats (total, avg latency, error rate) |
174
+
175
+ ### Metrics Inspector
176
+ | Tool | Description |
177
+ |------|-------------|
178
+ | `get_registered_metrics` | List all registered Prometheus metrics |
179
+ | `get_metric_value` | Get current value of a specific metric by name |
180
+
181
+ ### ActiveRecord Stats Inspector
182
+ | Tool | Description |
183
+ |------|-------------|
184
+ | `get_active_record_query_stats` | ActiveRecord query statistics (queries per model, N+1 detection) |
185
+
186
+ ### Faraday Inspector
187
+ | Tool | Description |
188
+ |------|-------------|
189
+ | `get_faraday_connections` | List Faraday connection objects with host, port, and adapter info |
190
+
191
+ ### Concurrent Inspector
192
+ | Tool | Description |
193
+ |------|-------------|
194
+ | `get_concurrent_state` | Ruby concurrency primitives state (Mutex, ConditionVariable, Queue) |
195
+
114
196
  ## Custom Tools
115
197
 
116
198
  ```ruby
@@ -133,12 +215,36 @@ end
133
215
 
134
216
  ## Run the Demo
135
217
 
218
+ The demo uses **Sinatra** + **redis-rb** + **SQLite** + **Sidekiq**. Start Redis with Docker Compose first:
219
+
220
+ ### Docker Compose
221
+
222
+ ```yaml
223
+ # docker-compose.yml
224
+ services:
225
+ redis:
226
+ image: redis:7-alpine
227
+ ports:
228
+ - "6379:6379"
229
+ command: redis-server --save 60 1 --loglevel warning
230
+ ```
231
+
232
+ ```bash
233
+ docker compose up -d
234
+ ```
235
+
236
+ ### Start the app
237
+
136
238
  ```bash
137
239
  export LLM_API_KEY=your-key
138
240
  cd demo && ruby -I../lib app.rb
139
241
  # Open http://localhost:4567/agent
140
242
  ```
141
243
 
244
+ ## RubyGems
245
+
246
+ [![Gem](https://img.shields.io/badge/rubygems-debug--agent-red)](https://github.com/topcheer/ruby-debug-agent)
247
+
142
248
  ## License
143
249
 
144
250
  MIT
@@ -0,0 +1,131 @@
1
+ require 'thread'
2
+
3
+ module DebugAgent
4
+ # Track ActiveRecord SQL events (sql.active_record) to report query stats
5
+ # and N+1 detection hints. Auto-installs a subscriber when the tool is run.
6
+ @ar_stats = {
7
+ total_queries: 0,
8
+ total_time_ms: 0.0,
9
+ slow_queries: [],
10
+ query_counts: Hash.new(0),
11
+ query_times: Hash.new(0.0),
12
+ slow_threshold_ms: 100
13
+ }
14
+ @ar_lock = Mutex.new
15
+ @ar_tracker_installed = false
16
+
17
+ class << self
18
+ attr_reader :ar_stats
19
+
20
+ # Subscribe to sql.active_record notifications (idempotent).
21
+ def install_ar_tracker
22
+ return true if @ar_tracker_installed
23
+ return false unless defined?(::ActiveSupport::Notifications)
24
+
25
+ ::ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, started, finished, _id, payload|
26
+ DebugAgent.record_ar_query(started, finished, payload)
27
+ end
28
+ @ar_tracker_installed = true
29
+ true
30
+ end
31
+
32
+ def record_ar_query(started, finished, payload)
33
+ duration_ms = ((finished - started) * 1000.0)
34
+ sql = payload[:sql].to_s.strip
35
+ return if sql.empty?
36
+
37
+ fp = ar_fingerprint(sql)
38
+ name = payload[:name]
39
+
40
+ @ar_lock.synchronize do
41
+ s = @ar_stats
42
+ s[:total_queries] += 1
43
+ s[:total_time_ms] += duration_ms
44
+ next unless fp
45
+ s[:query_counts][fp] += 1
46
+ s[:query_times][fp] += duration_ms
47
+ if duration_ms >= s[:slow_threshold_ms] && s[:slow_queries].size < 200
48
+ s[:slow_queries] << {
49
+ sql: sql[0..500],
50
+ name: name,
51
+ duration_ms: duration_ms.round(2),
52
+ timestamp: finished.to_s
53
+ }
54
+ end
55
+ end
56
+ rescue
57
+ nil
58
+ end
59
+
60
+ # Normalize SQL into a fingerprint for grouping / N+1 detection.
61
+ def ar_fingerprint(sql)
62
+ sql
63
+ .gsub(/'[^']*'/, '?')
64
+ .gsub(/\b\d+\b/, '?')
65
+ .gsub(/\s+/, ' ')
66
+ .strip[0..200]
67
+ end
68
+ end
69
+
70
+ register_tool('get_active_record_query_stats',
71
+ 'Query statistics from ActiveRecord: total queries, avg query time, ' \
72
+ 'slow queries, and N+1 detection hints (subscribes to sql.active_record)') do
73
+ next { error: 'ActiveRecord is not loaded (activerecord gem not installed)' } unless defined?(::ActiveRecord)
74
+
75
+ install_ar_tracker
76
+
77
+ snapshot = @ar_lock.synchronize do
78
+ {
79
+ total_queries: @ar_stats[:total_queries],
80
+ total_time_ms: @ar_stats[:total_time_ms].round(2),
81
+ slow_queries: @ar_stats[:slow_queries].dup,
82
+ query_counts: @ar_stats[:query_counts].dup,
83
+ query_times: @ar_stats[:query_times].dup,
84
+ slow_threshold_ms: @ar_stats[:slow_threshold_ms]
85
+ }
86
+ end
87
+
88
+ total = snapshot[:total_queries]
89
+ avg = total.zero? ? 0.0 : (snapshot[:total_time_ms] / total)
90
+
91
+ # N+1 suspects: the same query fingerprint executed many times.
92
+ n_plus_suspects =
93
+ snapshot[:query_counts]
94
+ .select { |_, count| count >= 5 }
95
+ .sort_by { |_, count| -count }
96
+ .first(10)
97
+ .map do |fp, count|
98
+ time = snapshot[:query_times][fp]
99
+ {
100
+ fingerprint: fp,
101
+ count: count,
102
+ total_time_ms: time.round(2),
103
+ avg_time_ms: (time / count).round(2)
104
+ }
105
+ end
106
+
107
+ result = {
108
+ tracker_active: @ar_tracker_installed,
109
+ total_queries: total,
110
+ total_query_time_ms: snapshot[:total_time_ms],
111
+ avg_query_time_ms: avg.round(2),
112
+ slow_query_threshold_ms: snapshot[:slow_threshold_ms],
113
+ slow_query_count: snapshot[:slow_queries].size,
114
+ slow_queries: snapshot[:slow_queries].last(20).reverse,
115
+ n_plus_suspects: n_plus_suspects
116
+ }
117
+
118
+ # Best-effort: include query cache info if available.
119
+ if ::ActiveRecord::Base.connection.respond_to?(:query_cache)
120
+ begin
121
+ result[:query_cache_enabled] = ::ActiveRecord::Base.connection.query_cache_enabled
122
+ rescue
123
+ nil
124
+ end
125
+ end
126
+
127
+ result
128
+ rescue => e
129
+ { error: e.message }
130
+ end
131
+ end
@@ -0,0 +1,194 @@
1
+ module DebugAgent
2
+ # Registry of named cache objects. Applications register caches so the
3
+ # inspector can introspect stats, keys, and clear them.
4
+ #
5
+ # DebugAgent.register_cache(:rails, Rails.cache)
6
+ @caches = {}
7
+
8
+ class << self
9
+ attr_reader :caches
10
+
11
+ def register_cache(name, cache)
12
+ @caches[name.to_s] = cache
13
+ end
14
+
15
+ # Introspect a cache object, supporting ActiveSupport::Cache::MemoryStore,
16
+ # generic ActiveSupport::Cache::Store, plain Hashes, and custom caches.
17
+ def cache_stats_for(cache)
18
+ if defined?(::ActiveSupport::Cache::MemoryStore) && cache.is_a?(::ActiveSupport::Cache::MemoryStore)
19
+ data = cache.instance_variable_get(:@data) || {}
20
+ key_access = cache.instance_variable_get(:@key_access) || {}
21
+ {
22
+ type: 'ActiveSupport::Cache::MemoryStore',
23
+ size: data.size,
24
+ max_size: cache.instance_variable_get(:@max_size),
25
+ tracked_keys: key_access.size,
26
+ sample_keys: data.keys.first(50)
27
+ }
28
+ elsif defined?(::ActiveSupport::Cache::Store) && defined?(::ActiveSupport::Cache) &&
29
+ cache.is_a?(::ActiveSupport::Cache::Store)
30
+ stats = best_effort_cache_stats(cache)
31
+ stats.merge(type: cache.class.name)
32
+ elsif cache.is_a?(Hash)
33
+ {
34
+ type: 'Hash',
35
+ size: cache.size,
36
+ sample_keys: cache.keys.first(50)
37
+ }
38
+ else
39
+ stats = best_effort_cache_stats(cache)
40
+ stats.merge(type: cache.class.name)
41
+ end
42
+ end
43
+
44
+ # Extract hit/miss and size where the cache exposes them (e.g. Dalli).
45
+ def best_effort_cache_stats(cache)
46
+ result = {}
47
+ result[:size] = cache_size_for(cache)
48
+
49
+ if cache.respond_to?(:stats)
50
+ raw =
51
+ begin
52
+ cache.stats
53
+ rescue
54
+ {}
55
+ end
56
+ if raw.is_a?(Hash)
57
+ result[:raw_stats] = raw
58
+ hits = raw['get_hits'] || raw[:get_hits]
59
+ misses = raw['get_misses'] || raw[:get_misses]
60
+ if hits && misses
61
+ total = hits.to_i + misses.to_i
62
+ result[:hits] = hits.to_i
63
+ result[:misses] = misses.to_i
64
+ result[:hit_rate] = total.zero? ? nil : format('%.1f%%', hits.to_f / total * 100)
65
+ end
66
+ end
67
+ end
68
+ result
69
+ end
70
+
71
+ def cache_keys_for(cache)
72
+ if defined?(::ActiveSupport::Cache::MemoryStore) && cache.is_a?(::ActiveSupport::Cache::MemoryStore)
73
+ (cache.instance_variable_get(:@data) || {}).keys
74
+ elsif cache.is_a?(Hash)
75
+ cache.keys
76
+ elsif cache.respond_to?(:keys)
77
+ begin
78
+ cache.keys
79
+ rescue
80
+ []
81
+ end
82
+ else
83
+ []
84
+ end
85
+ end
86
+
87
+ def cache_size_for(cache)
88
+ if defined?(::ActiveSupport::Cache::MemoryStore) && cache.is_a?(::ActiveSupport::Cache::MemoryStore)
89
+ (cache.instance_variable_get(:@data) || {}).size
90
+ elsif cache.respond_to?(:size)
91
+ begin
92
+ cache.size
93
+ rescue
94
+ nil
95
+ end
96
+ elsif cache.respond_to?(:length)
97
+ begin
98
+ cache.length
99
+ rescue
100
+ nil
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ register_tool('get_cache_stats',
107
+ 'Get stats for registered caches: hit/miss ratio, size, entries. ' \
108
+ 'Supports Rails.cache, ActiveSupport::Cache::MemoryStore, and Hash caches') do |name: nil|
109
+ if caches.empty?
110
+ next { error: 'No caches registered. Call DebugAgent.register_cache(:name, cache).' }
111
+ end
112
+
113
+ targets = name ? { name.to_s => caches[name.to_s] } : caches
114
+ targets = targets.reject { |_, c| c.nil? }
115
+ next { error: "No cache registered under '#{name}'" } if targets.empty?
116
+
117
+ results = targets.map do |cache_name, cache|
118
+ begin
119
+ cache_stats_for(cache).merge(name: cache_name)
120
+ rescue => e
121
+ { name: cache_name, error: e.message }
122
+ end
123
+ end
124
+
125
+ { caches: results }
126
+ rescue => e
127
+ { error: e.message }
128
+ end
129
+
130
+ register_tool('get_cache_keys',
131
+ 'List keys in a registered cache with optional prefix filter',
132
+ name: { type: 'string', description: 'Registered cache name (optional, defaults to first)', required: false },
133
+ prefix: { type: 'string', description: 'Only return keys starting with this prefix', required: false }) do |name: nil, prefix: nil|
134
+ if caches.empty?
135
+ next { error: 'No caches registered. Call DebugAgent.register_cache(:name, cache).' }
136
+ end
137
+
138
+ cache_name, cache = name ? [name.to_s, caches[name.to_s]] : caches.first
139
+ next { error: "No cache registered under '#{name}'" } unless cache
140
+
141
+ keys = cache_keys_for(cache)
142
+ if prefix && !prefix.to_s.empty?
143
+ keys = keys.select { |k| k.to_s.start_with?(prefix.to_s) }
144
+ end
145
+
146
+ {
147
+ cache: cache_name,
148
+ total_keys: keys.size,
149
+ keys: keys.first(500)
150
+ }
151
+ rescue => e
152
+ { error: e.message }
153
+ end
154
+
155
+ register_tool('clear_cache',
156
+ 'Clear a registered cache (destructive: removes all entries)',
157
+ name: { type: 'string', description: 'Registered cache name (optional, defaults to first)', required: false }) do |name: nil|
158
+ if caches.empty?
159
+ next { error: 'No caches registered. Call DebugAgent.register_cache(:name, cache).' }
160
+ end
161
+
162
+ cache_name, cache = name ? [name.to_s, caches[name.to_s]] : caches.first
163
+ next { error: "No cache registered under '#{name}'" } unless cache
164
+
165
+ before = cache_size_for(cache)
166
+
167
+ cleared =
168
+ if cache.respond_to?(:clear)
169
+ cache.clear
170
+ true
171
+ elsif cache.is_a?(Hash)
172
+ cache.clear
173
+ true
174
+ elsif cache.respond_to?(:clear_all)
175
+ cache.clear_all
176
+ true
177
+ elsif cache.respond_to?(:delete_all)
178
+ cache.delete_all
179
+ true
180
+ elsif cache.respond_to?(:flushdb)
181
+ cache.flushdb
182
+ true
183
+ else
184
+ false
185
+ end
186
+
187
+ next { error: "Cache '#{cache_name}' (#{cache.class}) does not support clearing" } unless cleared
188
+
189
+ after = cache_size_for(cache)
190
+ { cache: cache_name, cleared: true, size_before: before, size_after: after }
191
+ rescue => e
192
+ { error: e.message }
193
+ end
194
+ end
@@ -0,0 +1,78 @@
1
+ module DebugAgent
2
+ # Inspector for the concurrent-ruby gem: global executor pools and any
3
+ # registered promises/futures.
4
+ #
5
+ # DebugAgent.register_concurrent(:my_task, Concurrent::Promises.future { ... })
6
+ @concurrent_promises = {}
7
+
8
+ class << self
9
+ attr_reader :concurrent_promises
10
+
11
+ def register_concurrent(name, promise)
12
+ @concurrent_promises[name.to_s] = promise
13
+ end
14
+
15
+ def executor_info(executor)
16
+ return nil unless executor
17
+ info = { class: executor.class.name }
18
+ %i[running?].each do |m|
19
+ info[m] = executor.public_send(m) if executor.respond_to?(m)
20
+ end
21
+ %i[length largest_length queue_length scheduled_task_count completed_task_count
22
+ max_threads min_threads idletime max_queue].each do |m|
23
+ info[m] = (executor.public_send(m) rescue nil) if executor.respond_to?(m)
24
+ end
25
+ info
26
+ rescue => e
27
+ { class: executor&.class&.name, error: e.message }
28
+ end
29
+
30
+ def promise_info(name, promise)
31
+ info = { name: name, class: promise.class.name }
32
+ info[:state] = promise.state if promise.respond_to?(:state)
33
+ if promise.respond_to?(:fulfilled?)
34
+ info[:fulfilled] = promise.fulfilled?
35
+ info[:rejected] = promise.rejected? if promise.respond_to?(:rejected?)
36
+ info[:pending] = promise.pending? if promise.respond_to?(:pending?)
37
+ end
38
+ if promise.respond_to?(:reason)
39
+ reason = promise.reason
40
+ info[:reason] = reason.is_a?(Exception) ? reason.message : reason.inspect unless reason.nil?
41
+ end
42
+ info
43
+ rescue => e
44
+ { name: name, error: e.message }
45
+ end
46
+ end
47
+
48
+ register_tool('get_concurrent_state',
49
+ 'If concurrent-ruby is loaded, list global executor pools and registered ' \
50
+ 'promises/futures with their state') do
51
+ next { error: 'concurrent-ruby is not loaded (concurrent-ruby gem not installed)' } unless defined?(::Concurrent)
52
+
53
+ executors = {}
54
+ if ::Concurrent.respond_to?(:global_io_executor)
55
+ executors[:global_io] = executor_info(::Concurrent.global_io_executor)
56
+ end
57
+ if ::Concurrent.respond_to?(:global_fast_executor)
58
+ executors[:global_fast] = executor_info(::Concurrent.global_fast_executor)
59
+ end
60
+ if ::Concurrent.respond_to?(:global_immediate_executor)
61
+ executors[:global_immediate] = executor_info(::Concurrent.global_immediate_executor)
62
+ end
63
+
64
+ promises =
65
+ if concurrent_promises.empty?
66
+ { message: 'No promises/futures registered. Call DebugAgent.register_concurrent(:name, future).' }
67
+ else
68
+ concurrent_promises.map { |n, p| promise_info(n, p) }
69
+ end
70
+
71
+ {
72
+ executors: executors,
73
+ registered_promises: promises
74
+ }
75
+ rescue => e
76
+ { error: e.message }
77
+ end
78
+ end
@@ -0,0 +1,105 @@
1
+ module DebugAgent
2
+ register_tool('get_fiber_info',
3
+ 'List alive fibers (Ruby 3.0+ via Fiber.list) with status and backtrace') do
4
+ unless Fiber.respond_to?(:list)
5
+ return {
6
+ supported: false,
7
+ message: 'Fiber.list requires Ruby 3.0+. ' \
8
+ "Current Ruby: #{RUBY_VERSION} (#{RUBY_ENGINE})"
9
+ }
10
+ end
11
+
12
+ fibers = Fiber.list.map do |fiber|
13
+ backtrace =
14
+ begin
15
+ fiber.backtrace || []
16
+ rescue => e
17
+ ["<unable to get backtrace: #{e.message}>"]
18
+ end
19
+
20
+ {
21
+ object_id: fiber.object_id,
22
+ to_s: fiber.to_s,
23
+ alive: fiber.alive?,
24
+ resizable: fiber.respond_to?(:resizable?) ? fiber.resizable? : nil,
25
+ storage: fiber.respond_to?(:storage) ? (fiber.storage&.keys&.map(&:to_s) rescue nil) : nil,
26
+ backtrace_summary: backtrace.first(5),
27
+ backtrace_length: backtrace.size
28
+ }
29
+ end
30
+
31
+ {
32
+ supported: true,
33
+ total_fibers: fibers.size,
34
+ alive_fibers: fibers.count { |f| f[:alive] },
35
+ fibers: fibers
36
+ }
37
+ rescue => e
38
+ { error: e.message }
39
+ end
40
+
41
+ register_tool('get_signal_handlers',
42
+ 'List registered signal handlers (Signal.trap) and default handlers') do
43
+ handlers = []
44
+
45
+ Signal.list.each do |name, number|
46
+ begin
47
+ current = Signal.trap(name)
48
+ rescue => e
49
+ current = "<error: #{e.message}>"
50
+ end
51
+
52
+ handlers << {
53
+ signal: name,
54
+ number: number,
55
+ handler:
56
+ case current
57
+ when 'DEFAULT' then 'DEFAULT (system default)'
58
+ when 'IGNORE' then 'IGNORE (ignored)'
59
+ when 'EXIT' then 'EXIT (terminate process)'
60
+ when 'SYSTEM_DEFAULT' then 'SYSTEM_DEFAULT'
61
+ when String then current
62
+ when Proc
63
+ begin
64
+ src = current.source_location
65
+ src ? "Proc at #{src.join(':')}" : 'Proc (unknown source)'
66
+ rescue
67
+ 'Proc'
68
+ end
69
+ else
70
+ current.inspect
71
+ end
72
+ }
73
+ end
74
+
75
+ {
76
+ total_signals: handlers.size,
77
+ signals: handlers.sort_by { |h| h[:number] }
78
+ }
79
+ rescue => e
80
+ { error: e.message }
81
+ end
82
+
83
+ register_tool('get_encoding_info',
84
+ 'Get Ruby encoding info: Encoding.list, default external/internal/locale encodings') do
85
+ list = Encoding.list.map do |enc|
86
+ {
87
+ name: enc.name,
88
+ aliases: Encoding.aliases.select { |_, n| n == enc.name }.keys,
89
+ dummy: enc.dummy?,
90
+ ascii_compatible: enc.ascii_compatible?
91
+ }
92
+ end
93
+
94
+ {
95
+ total_encodings: list.size,
96
+ default_external: Encoding.default_external.to_s,
97
+ default_internal: Encoding.default_internal&.to_s,
98
+ locale: Encoding.find('locale').to_s,
99
+ filesystem: Encoding.find('filesystem').to_s,
100
+ encodings: list
101
+ }
102
+ rescue => e
103
+ { error: e.message }
104
+ end
105
+ end