debug-agent 0.4.0 → 0.5.1
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 +4 -4
- data/README.md +18 -2
- data/lib/debug_agent/inspectors/error_tracking.rb +120 -0
- data/lib/debug_agent/inspectors/health.rb +110 -0
- data/lib/debug_agent/inspectors/scheduler.rb +154 -0
- data/lib/debug_agent/inspectors/security.rb +201 -0
- data/lib/debug_agent/inspectors/websocket.rb +188 -0
- data/lib/debug_agent/llm_client.rb +56 -56
- data/lib/debug_agent/version.rb +1 -1
- data/lib/debug_agent.rb +5 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6811d0a31e6c030eb3883ec27085c87cdaa7f9e0217e4e0e7b25cba7d3f2fc6b
|
|
4
|
+
data.tar.gz: a327319653c66f29a8099d468739c45e683b47891ffb7952b705a4ea016dcfc6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3a8d3017f048ea920c9051e148b129e74da019693d12dff637ed023eecca16a97d82ef887d2943f6d5f2983968d4d8f5cd259da6513498fdc3653db5b36d42dd
|
|
7
|
+
data.tar.gz: 79429a53f7be512a9f58d69e4d0948a3e29bffe2f27ce06733154189ca54441e683337cf1ca57acbc58fefe86bd5236462e24371bc12646659a824832cbe4a63
|
data/README.md
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
# Ruby Debug Agent
|
|
2
2
|
|
|
3
3
|
[](https://github.com/topcheer/ruby-debug-agent)
|
|
4
|
-

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
6
8
|
|
|
7
9
|
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**.
|
|
8
10
|
|
|
11
|
+
## Version Support
|
|
12
|
+
|
|
13
|
+
| Ruby Version | Status |
|
|
14
|
+
|--------------|--------|
|
|
15
|
+
| 2.6 | Not supported |
|
|
16
|
+
| 2.7 | Minimum supported |
|
|
17
|
+
| 3.0 | Supported (Fiber.list available) |
|
|
18
|
+
| 3.1 | Supported |
|
|
19
|
+
| 3.2 | Supported |
|
|
20
|
+
| 3.3 | Supported |
|
|
21
|
+
| 3.4 | Tested |
|
|
22
|
+
|
|
23
|
+
> Requires Ruby 2.7+ for pattern matching guards. Framework inspectors (Rails, Sidekiq, Puma) are optional and auto-detected via `defined?`.
|
|
24
|
+
|
|
9
25
|
## Quick Start
|
|
10
26
|
|
|
11
27
|
### 1. Install
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
require 'thread'
|
|
3
|
+
|
|
4
|
+
module DebugAgent
|
|
5
|
+
MAX_ERRORS = 50
|
|
6
|
+
@error_buffer = []
|
|
7
|
+
@error_buffer_lock = Mutex.new
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
attr_reader :error_buffer
|
|
11
|
+
|
|
12
|
+
def record_error(error, context = {})
|
|
13
|
+
@error_buffer_lock.synchronize do
|
|
14
|
+
@error_buffer << {
|
|
15
|
+
timestamp: Time.now.iso8601,
|
|
16
|
+
class: error.class.name,
|
|
17
|
+
message: error.message,
|
|
18
|
+
backtrace: (error.backtrace || []).first(10),
|
|
19
|
+
context: context
|
|
20
|
+
}
|
|
21
|
+
@error_buffer.shift if @error_buffer.size > MAX_ERRORS
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Capture unhandled exceptions at process exit
|
|
27
|
+
at_exit do
|
|
28
|
+
if $!
|
|
29
|
+
DebugAgent.record_error($!)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
register_tool('get_recent_errors',
|
|
34
|
+
'Get recent unhandled exceptions captured by the agent (ring buffer, max 50). ' \
|
|
35
|
+
'Each entry: timestamp, class, message, backtrace',
|
|
36
|
+
limit: { type: 'integer', description: 'Maximum number of errors to return (default 20)', required: false }) do |limit: 20|
|
|
37
|
+
errors = @error_buffer_lock.synchronize { @error_buffer.dup }
|
|
38
|
+
|
|
39
|
+
if errors.empty?
|
|
40
|
+
next {
|
|
41
|
+
message: 'No errors captured yet. Errors are recorded via at_exit and when ' \
|
|
42
|
+
'DebugAgent.record_error is called (e.g. from a Sinatra error handler).',
|
|
43
|
+
total: 0
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
limit = limit.to_i
|
|
48
|
+
limit = 20 if limit <= 0
|
|
49
|
+
|
|
50
|
+
{ total: errors.size, errors: errors.reverse.first(limit) }
|
|
51
|
+
rescue => e
|
|
52
|
+
{ error: e.message }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
register_tool('get_error_stats',
|
|
56
|
+
'Get error statistics: total errors, rate per minute, and top error types') do
|
|
57
|
+
errors = @error_buffer_lock.synchronize { @error_buffer.dup }
|
|
58
|
+
|
|
59
|
+
if errors.empty?
|
|
60
|
+
next { total: 0, message: 'No errors captured yet.' }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Group by error class
|
|
64
|
+
by_class = errors.group_by { |e| e[:class] }
|
|
65
|
+
top_types = by_class.map do |klass, entries|
|
|
66
|
+
{ class: klass, count: entries.size, last_seen: entries.last[:timestamp] }
|
|
67
|
+
end.sort_by { |t| -t[:count] }
|
|
68
|
+
|
|
69
|
+
# Calculate rate per minute (errors in the last 60 seconds)
|
|
70
|
+
now = Time.now
|
|
71
|
+
recent = errors.select do |e|
|
|
72
|
+
begin
|
|
73
|
+
(now - Time.iso8601(e[:timestamp])) <= 60
|
|
74
|
+
rescue
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
total: errors.size,
|
|
81
|
+
buffer_capacity: MAX_ERRORS,
|
|
82
|
+
rate_per_minute: recent.size,
|
|
83
|
+
unique_error_types: by_class.size,
|
|
84
|
+
top_error_types: top_types.first(10),
|
|
85
|
+
first_error: errors.first[:timestamp],
|
|
86
|
+
last_error: errors.last[:timestamp]
|
|
87
|
+
}
|
|
88
|
+
rescue => e
|
|
89
|
+
{ error: e.message }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
register_tool('get_error_patterns',
|
|
93
|
+
'Group captured errors by exception class to identify recurring patterns') do
|
|
94
|
+
errors = @error_buffer_lock.synchronize { @error_buffer.dup }
|
|
95
|
+
|
|
96
|
+
if errors.empty?
|
|
97
|
+
next { total: 0, message: 'No errors captured yet.' }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
patterns = errors.group_by { |e| e[:class] }.map do |klass, entries|
|
|
101
|
+
sample_messages = entries.map { |e| e[:message] }.uniq.first(5)
|
|
102
|
+
{
|
|
103
|
+
class: klass,
|
|
104
|
+
count: entries.size,
|
|
105
|
+
sample_messages: sample_messages,
|
|
106
|
+
first_seen: entries.first[:timestamp],
|
|
107
|
+
last_seen: entries.last[:timestamp],
|
|
108
|
+
sample_backtrace: entries.last[:backtrace]
|
|
109
|
+
}
|
|
110
|
+
end.sort_by { |p| -p[:count] }
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
total_errors: errors.size,
|
|
114
|
+
unique_patterns: patterns.size,
|
|
115
|
+
patterns: patterns
|
|
116
|
+
}
|
|
117
|
+
rescue => e
|
|
118
|
+
{ error: e.message }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
# Registry of health check blocks. Each block returns a hash with at least
|
|
3
|
+
# a :status key ('UP', 'DOWN', or 'DEGRADED').
|
|
4
|
+
#
|
|
5
|
+
# DebugAgent.register_health_check(:database) { { status: 'UP' } }
|
|
6
|
+
@health_checks = {}
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
attr_reader :health_checks
|
|
10
|
+
|
|
11
|
+
def register_health_check(name, &block)
|
|
12
|
+
@health_checks[name.to_s] = block
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
register_tool('get_health_status',
|
|
17
|
+
'Run all registered health checks and report status per component: UP, DOWN, or DEGRADED') do
|
|
18
|
+
if health_checks.empty?
|
|
19
|
+
next {
|
|
20
|
+
message: 'No health checks registered. Call DebugAgent.register_health_check(:name) { ... }.',
|
|
21
|
+
overall_status: 'UNKNOWN'
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
results = {}
|
|
26
|
+
up = 0
|
|
27
|
+
down = 0
|
|
28
|
+
degraded = 0
|
|
29
|
+
|
|
30
|
+
health_checks.each do |check_name, block|
|
|
31
|
+
begin
|
|
32
|
+
result = block.call
|
|
33
|
+
status = result.is_a?(Hash) ? (result[:status] || result['status'] || 'UP').to_s.upcase : 'UP'
|
|
34
|
+
results[check_name] = result.merge(status: status, latency_ms: nil)
|
|
35
|
+
|
|
36
|
+
case status
|
|
37
|
+
when 'UP' then up += 1
|
|
38
|
+
when 'DOWN' then down += 1
|
|
39
|
+
when 'DEGRADED' then degraded += 1
|
|
40
|
+
end
|
|
41
|
+
rescue => e
|
|
42
|
+
results[check_name] = { status: 'DOWN', error: e.message }
|
|
43
|
+
down += 1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
overall = if down > 0
|
|
48
|
+
'DOWN'
|
|
49
|
+
elsif degraded > 0
|
|
50
|
+
'DEGRADED'
|
|
51
|
+
else
|
|
52
|
+
'UP'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
overall_status: overall,
|
|
57
|
+
up: up,
|
|
58
|
+
down: down,
|
|
59
|
+
degraded: degraded,
|
|
60
|
+
total: health_checks.size,
|
|
61
|
+
components: results
|
|
62
|
+
}
|
|
63
|
+
rescue => e
|
|
64
|
+
{ error: e.message }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
register_tool('get_health_detail',
|
|
68
|
+
'Deep dive into a specific health check component for detailed diagnostics',
|
|
69
|
+
component_name: { type: 'string', description: 'Name of the health check component to inspect', required: true }) do |component_name:|
|
|
70
|
+
if health_checks.empty?
|
|
71
|
+
next { error: 'No health checks registered.' }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
key = component_name.to_s
|
|
75
|
+
block = health_checks[key]
|
|
76
|
+
next { error: "No health check registered for '#{component_name}'. Available: #{health_checks.keys.join(', ')}" } unless block
|
|
77
|
+
|
|
78
|
+
# Run the check multiple times to measure latency
|
|
79
|
+
samples = []
|
|
80
|
+
3.times do
|
|
81
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
begin
|
|
83
|
+
result = block.call
|
|
84
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0).round(2)
|
|
85
|
+
status = result.is_a?(Hash) ? (result[:status] || result['status'] || 'UP').to_s.upcase : 'UP'
|
|
86
|
+
samples << { status: status, latency_ms: elapsed, detail: result }
|
|
87
|
+
rescue => e
|
|
88
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0).round(2)
|
|
89
|
+
samples << { status: 'DOWN', latency_ms: elapsed, error: e.message }
|
|
90
|
+
break
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
latencies = samples.map { |s| s[:latency_ms] }
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
component: key,
|
|
98
|
+
registered_checks: health_checks.keys,
|
|
99
|
+
latest: samples.last,
|
|
100
|
+
samples: samples.size,
|
|
101
|
+
latency_ms: {
|
|
102
|
+
min: latencies.min,
|
|
103
|
+
avg: (latencies.sum / latencies.size.to_f).round(2),
|
|
104
|
+
max: latencies.max
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
rescue => e
|
|
108
|
+
{ error: e.message }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
# Registry of scheduled jobs (Sidekiq::Cron, rufus-scheduler, whenever, or
|
|
3
|
+
# custom Thread-based timers). Applications register jobs so the inspector
|
|
4
|
+
# can list them and report execution history.
|
|
5
|
+
#
|
|
6
|
+
# DebugAgent.register_scheduled_job(:cleanup, 'every 30s', last_run: Time.now)
|
|
7
|
+
@scheduled_jobs = {}
|
|
8
|
+
@job_history = {}
|
|
9
|
+
@scheduler_lock = Mutex.new
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
attr_reader :scheduled_jobs, :job_history
|
|
13
|
+
|
|
14
|
+
def register_scheduled_job(name, schedule, **opts)
|
|
15
|
+
@scheduled_jobs[name.to_s] = {
|
|
16
|
+
schedule: schedule,
|
|
17
|
+
name: name.to_s,
|
|
18
|
+
class: opts[:class] || name.to_s,
|
|
19
|
+
queue: opts[:queue],
|
|
20
|
+
enabled: opts.key?(:enabled) ? opts[:enabled] : true,
|
|
21
|
+
last_run: opts[:last_run],
|
|
22
|
+
next_run: opts[:next_run],
|
|
23
|
+
registered_at: Time.now
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Record a job execution for history tracking.
|
|
28
|
+
def record_job_execution(name, duration_ms, success: true, error: nil)
|
|
29
|
+
@scheduler_lock.synchronize do
|
|
30
|
+
history = (@job_history[name.to_s] ||= [])
|
|
31
|
+
history << {
|
|
32
|
+
timestamp: Time.now.iso8601,
|
|
33
|
+
duration_ms: duration_ms.round(2),
|
|
34
|
+
success: success,
|
|
35
|
+
error: error
|
|
36
|
+
}
|
|
37
|
+
history.shift if history.size > 100
|
|
38
|
+
|
|
39
|
+
# Update last_run on the job itself
|
|
40
|
+
if @scheduled_jobs[name.to_s]
|
|
41
|
+
@scheduled_jobs[name.to_s][:last_run] = Time.now
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
register_tool('get_scheduled_jobs',
|
|
48
|
+
'List scheduled jobs from Sidekiq::Cron, rufus-scheduler, whenever, ' \
|
|
49
|
+
'or custom Thread-based timers. Shows schedule, last run, and status') do
|
|
50
|
+
jobs = []
|
|
51
|
+
|
|
52
|
+
# Registered jobs (Thread-based, custom, etc.)
|
|
53
|
+
scheduled_jobs.each do |name, job|
|
|
54
|
+
jobs << {
|
|
55
|
+
name: name,
|
|
56
|
+
schedule: job[:schedule],
|
|
57
|
+
class: job[:class],
|
|
58
|
+
queue: job[:queue],
|
|
59
|
+
enabled: job[:enabled],
|
|
60
|
+
source: job[:source] || 'registered',
|
|
61
|
+
last_run: job[:last_run],
|
|
62
|
+
next_run: job[:next_run]
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Sidekiq::Cron jobs
|
|
67
|
+
if defined?(::Sidekiq::Cron::Job)
|
|
68
|
+
begin
|
|
69
|
+
::Sidekiq::Cron::Job.all.each do |cron_job|
|
|
70
|
+
jobs << {
|
|
71
|
+
name: cron_job.name,
|
|
72
|
+
schedule: cron_job.cron,
|
|
73
|
+
class: cron_job.klass,
|
|
74
|
+
queue: cron_job.queue_name,
|
|
75
|
+
enabled: cron_job.status == 'enabled',
|
|
76
|
+
source: 'sidekiq-cron',
|
|
77
|
+
last_run: cron_job.last_enqueue_time
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
rescue => e
|
|
81
|
+
jobs << { source: 'sidekiq-cron', error: e.message }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# rufus-scheduler
|
|
86
|
+
if defined?(::Rufus::Scheduler)
|
|
87
|
+
begin
|
|
88
|
+
ObjectSpace.each_object(::Rufus::Scheduler) do |scheduler|
|
|
89
|
+
scheduler.jobs.each do |job|
|
|
90
|
+
jobs << {
|
|
91
|
+
name: job.respond_to?(:tags) ? job.tags.first : nil,
|
|
92
|
+
schedule: job.respond_to?(:original) ? job.original : job.class.name,
|
|
93
|
+
class: job.class.name,
|
|
94
|
+
enabled: !job.respond_to?(:paused?) || !job.paused?,
|
|
95
|
+
source: 'rufus-scheduler',
|
|
96
|
+
last_run: job.respond_to?(:last_time) ? job.last_time : nil,
|
|
97
|
+
next_run: job.respond_to?(:next_time) ? job.next_time : nil
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
rescue => e
|
|
102
|
+
jobs << { source: 'rufus-scheduler', error: e.message }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if jobs.empty?
|
|
107
|
+
next {
|
|
108
|
+
message: 'No scheduled jobs registered. Call DebugAgent.register_scheduled_job(:name, schedule). ' \
|
|
109
|
+
'Also auto-detects Sidekiq::Cron and rufus-scheduler if loaded.',
|
|
110
|
+
total: 0
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
{ total: jobs.size, jobs: jobs }
|
|
115
|
+
rescue => e
|
|
116
|
+
{ error: e.message }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
register_tool('get_job_history',
|
|
120
|
+
'Get recent execution history for scheduled jobs: run times, duration, success/failure',
|
|
121
|
+
job_name: { type: 'string', description: 'Job name to filter history (optional, returns all if omitted)', required: false }) do |job_name: nil|
|
|
122
|
+
if job_history.empty?
|
|
123
|
+
next { message: 'No job execution history recorded. Jobs must call DebugAgent.record_job_execution to track history.', total: 0 }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if job_name
|
|
127
|
+
key = job_name.to_s
|
|
128
|
+
history = job_history[key] || []
|
|
129
|
+
next {
|
|
130
|
+
job: key,
|
|
131
|
+
total: history.size,
|
|
132
|
+
history: history.reverse.first(50)
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
all = job_history.map do |name, entries|
|
|
137
|
+
successful = entries.count { |e| e[:success] }
|
|
138
|
+
failed = entries.count { |e| !e[:success] }
|
|
139
|
+
durations = entries.map { |e| e[:duration_ms] }
|
|
140
|
+
{
|
|
141
|
+
job: name,
|
|
142
|
+
total_runs: entries.size,
|
|
143
|
+
successful: successful,
|
|
144
|
+
failed: failed,
|
|
145
|
+
avg_duration_ms: durations.empty? ? 0 : (durations.sum / durations.size.to_f).round(2),
|
|
146
|
+
last_run: entries.last&.dig(:timestamp)
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
{ total_jobs: all.size, total_runs: job_history.values.map(&:size).sum, jobs: all }
|
|
151
|
+
rescue => e
|
|
152
|
+
{ error: e.message }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
module DebugAgent
|
|
2
|
+
# Registry of auth configurations (Devise, Warden, OmniAuth, Rack auth).
|
|
3
|
+
#
|
|
4
|
+
# DebugAgent.register_auth_config(:api_key, { strategy: 'X-API-Key', secret_present: true })
|
|
5
|
+
@auth_configs = {}
|
|
6
|
+
|
|
7
|
+
# Registry of session stores (Rack::Session, ActiveRecord::Session, custom).
|
|
8
|
+
#
|
|
9
|
+
# DebugAgent.register_session_store(:rack_session, env['rack.session'])
|
|
10
|
+
@session_stores = {}
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
attr_reader :auth_configs, :session_stores
|
|
14
|
+
|
|
15
|
+
def register_auth_config(name, config)
|
|
16
|
+
@auth_configs[name.to_s] = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def register_session_store(name, store)
|
|
20
|
+
@session_stores[name.to_s] = store
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
register_tool('get_auth_config',
|
|
25
|
+
'List registered auth configurations (Devise, Warden, OmniAuth, Rack auth). ' \
|
|
26
|
+
'Shows strategy, whether a secret is present, and token expiry') do |name: nil|
|
|
27
|
+
# Auto-detect from loaded gems when nothing is registered
|
|
28
|
+
if auth_configs.empty?
|
|
29
|
+
auto = {}
|
|
30
|
+
if defined?(::Devise)
|
|
31
|
+
auto['Devise'] = {
|
|
32
|
+
strategy: 'Devise',
|
|
33
|
+
secret_present: defined?(Devise.secret_key) && !Devise.secret_key.to_s.empty?,
|
|
34
|
+
models: (Devise.mappings.keys.map(&:to_s) rescue []),
|
|
35
|
+
token_expiry: nil
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
if defined?(::Warden)
|
|
39
|
+
auto['Warden'] = {
|
|
40
|
+
strategy: 'Warden',
|
|
41
|
+
secret_present: !!(Warden::Manager.respond_to?(:secret) rescue false),
|
|
42
|
+
default_strategies: (Warden::Config.new.default_strategies rescue []),
|
|
43
|
+
token_expiry: nil
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
if defined?(::OmniAuth)
|
|
47
|
+
auto['OmniAuth'] = {
|
|
48
|
+
strategy: 'OmniAuth',
|
|
49
|
+
providers: (OmniAuth.strategies.map(&:to_s) rescue []),
|
|
50
|
+
secret_present: !!(OmniAuth.configuration rescue nil),
|
|
51
|
+
token_expiry: nil
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
if auto.any?
|
|
55
|
+
next { auth_configs: auto, source: 'auto-detected' }
|
|
56
|
+
end
|
|
57
|
+
next { error: 'No auth configs registered. Call DebugAgent.register_auth_config(:name, config).' }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
targets = name ? { name.to_s => auth_configs[name.to_s] } : auth_configs
|
|
61
|
+
targets = targets.reject { |_, c| c.nil? }
|
|
62
|
+
next { error: "No auth config registered under '#{name}'" } if targets.empty?
|
|
63
|
+
|
|
64
|
+
results = targets.map do |cfg_name, cfg|
|
|
65
|
+
begin
|
|
66
|
+
if cfg.is_a?(Hash)
|
|
67
|
+
normalized = {
|
|
68
|
+
name: cfg_name,
|
|
69
|
+
strategy: cfg[:strategy] || cfg['strategy'],
|
|
70
|
+
secret_present: cfg.key?(:secret_present) ? cfg[:secret_present] : cfg['secret_present'],
|
|
71
|
+
token_expiry: cfg[:token_expiry] || cfg['token_expiry']
|
|
72
|
+
}.merge(cfg.reject { |k, _| %i[strategy secret_present token_expiry].include?(k.to_sym) })
|
|
73
|
+
normalized
|
|
74
|
+
else
|
|
75
|
+
{ name: cfg_name, type: cfg.class.name, raw: cfg.inspect }
|
|
76
|
+
end
|
|
77
|
+
rescue => e
|
|
78
|
+
{ name: cfg_name, error: e.message }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{ auth_configs: results }
|
|
83
|
+
rescue => e
|
|
84
|
+
{ error: e.message }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
register_tool('get_active_sessions',
|
|
88
|
+
'List active sessions from registered session stores ' \
|
|
89
|
+
'(Rack::Session, ActiveRecord::Session). ' \
|
|
90
|
+
'Shows session ID, user, creation, and expiry') do |name: nil|
|
|
91
|
+
if session_stores.empty?
|
|
92
|
+
next { error: 'No session stores registered. Call DebugAgent.register_session_store(:name, store).' }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
targets = name ? { name.to_s => session_stores[name.to_s] } : session_stores
|
|
96
|
+
targets = targets.reject { |_, s| s.nil? }
|
|
97
|
+
next { error: "No session store registered under '#{name}'" } if targets.empty?
|
|
98
|
+
|
|
99
|
+
results = targets.map do |store_name, store|
|
|
100
|
+
begin
|
|
101
|
+
introspect_session_store(store_name, store)
|
|
102
|
+
rescue => e
|
|
103
|
+
{ name: store_name, error: e.message }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
{ session_stores: results }
|
|
108
|
+
rescue => e
|
|
109
|
+
{ error: e.message }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
register_tool('get_cors_config',
|
|
113
|
+
'Show CORS settings (Rack::Cors config: allowed origins, methods, headers)') do
|
|
114
|
+
# Try to find Rack::Cors middleware in the app middleware stack
|
|
115
|
+
cors_rules = []
|
|
116
|
+
|
|
117
|
+
if app && app.respond_to?(:middleware)
|
|
118
|
+
app.middleware.each do |middleware_entry|
|
|
119
|
+
middleware_class = middleware_entry.shift if middleware_entry.is_a?(Array)
|
|
120
|
+
next unless middleware_class.to_s.include?('Cors')
|
|
121
|
+
|
|
122
|
+
args = middleware_entry || []
|
|
123
|
+
cors_rules << { middleware: middleware_class.to_s, args: args.inspect }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Try Rack::Cors introspection
|
|
128
|
+
if defined?(::Rack::Cors)
|
|
129
|
+
begin
|
|
130
|
+
rack_cors = ::Rack::Cors
|
|
131
|
+
cors_rules << { middleware: 'Rack::Cors', version: rack_cors.respond_to?(:VERSION) ? rack_cors::VERSION : 'unknown' }
|
|
132
|
+
rescue => e
|
|
133
|
+
cors_rules << { middleware: 'Rack::Cors', error: e.message }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if cors_rules.empty?
|
|
138
|
+
next {
|
|
139
|
+
message: 'No CORS configuration detected. Install rack-cors and configure Rack::Cors middleware, ' \
|
|
140
|
+
'or the inspector could not find CORS rules in the middleware stack.',
|
|
141
|
+
cors_enabled: false
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
{ cors_enabled: true, rules: cors_rules }
|
|
146
|
+
rescue => e
|
|
147
|
+
{ error: e.message }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# --- Helpers ---
|
|
151
|
+
|
|
152
|
+
def self.introspect_session_store(store_name, store)
|
|
153
|
+
info = { name: store_name, type: store.class.name }
|
|
154
|
+
|
|
155
|
+
# Rack::Session::Abstract::SessionHash or similar
|
|
156
|
+
if store.respond_to?(:to_hash)
|
|
157
|
+
begin
|
|
158
|
+
data = store.to_hash
|
|
159
|
+
info[:session_count] = data.size
|
|
160
|
+
info[:keys] = data.keys.first(50)
|
|
161
|
+
info[:has_user] = data.key?('user_id') || data.key?(:user_id) || data.key?('user')
|
|
162
|
+
rescue
|
|
163
|
+
info[:session_count] = 'unable to read'
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# ActiveRecord::SessionStore
|
|
168
|
+
if store.respond_to?(:all)
|
|
169
|
+
begin
|
|
170
|
+
sessions = store.all.to_a
|
|
171
|
+
info[:session_count] = sessions.size
|
|
172
|
+
info[:sessions] = sessions.first(20).map do |sess|
|
|
173
|
+
sess_data = begin
|
|
174
|
+
sess.respond_to?(:data) ? sess.data.keys : []
|
|
175
|
+
rescue
|
|
176
|
+
[]
|
|
177
|
+
end
|
|
178
|
+
{
|
|
179
|
+
session_id: sess.respond_to?(:session_id) ? sess.session_id : sess.id,
|
|
180
|
+
data: sess_data,
|
|
181
|
+
created_at: sess.respond_to?(:created_at) ? sess.created_at : nil,
|
|
182
|
+
updated_at: sess.respond_to?(:updated_at) ? sess.updated_at : nil
|
|
183
|
+
}
|
|
184
|
+
end
|
|
185
|
+
rescue
|
|
186
|
+
info[:session_count] = 'unable to read'
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Generic session store with session_id method
|
|
191
|
+
if store.respond_to?(:session_id)
|
|
192
|
+
begin
|
|
193
|
+
info[:session_id] = store.session_id
|
|
194
|
+
rescue
|
|
195
|
+
# ignore
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
info
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
require 'thread'
|
|
3
|
+
|
|
4
|
+
module DebugAgent
|
|
5
|
+
# Registry of WebSocket servers (faye-websocket, websocket-driver, ActionCable).
|
|
6
|
+
# Applications register WS server objects so the inspector can introspect them.
|
|
7
|
+
#
|
|
8
|
+
# DebugAgent.register_ws_server(:faye, ws_server)
|
|
9
|
+
@ws_servers = {}
|
|
10
|
+
@ws_connections = []
|
|
11
|
+
@ws_lock = Mutex.new
|
|
12
|
+
@ws_message_log = []
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
attr_reader :ws_servers, :ws_connections, :ws_message_log
|
|
16
|
+
|
|
17
|
+
def register_ws_server(name, server)
|
|
18
|
+
@ws_servers[name.to_s] = server
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Track a WebSocket connection
|
|
22
|
+
def track_ws_connection(conn_id, remote_addr, channel = nil)
|
|
23
|
+
@ws_lock.synchronize do
|
|
24
|
+
@ws_connections << {
|
|
25
|
+
id: conn_id,
|
|
26
|
+
remote_addr: remote_addr,
|
|
27
|
+
channel: channel,
|
|
28
|
+
connected_since: Time.now.iso8601,
|
|
29
|
+
messages_sent: 0,
|
|
30
|
+
messages_received: 0
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
conn_id
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Remove a tracked WebSocket connection
|
|
37
|
+
def untrack_ws_connection(conn_id)
|
|
38
|
+
@ws_lock.synchronize do
|
|
39
|
+
@ws_connections.reject! { |c| c[:id] == conn_id }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Log a WebSocket message
|
|
44
|
+
def log_ws_message(conn_id, direction, data, size = nil)
|
|
45
|
+
@ws_lock.synchronize do
|
|
46
|
+
@ws_message_log << {
|
|
47
|
+
timestamp: Time.now.iso8601,
|
|
48
|
+
connection_id: conn_id,
|
|
49
|
+
direction: direction, # 'sent' or 'received'
|
|
50
|
+
size: size || data.to_s.bytesize,
|
|
51
|
+
preview: data.to_s[0...200]
|
|
52
|
+
}
|
|
53
|
+
@ws_message_log.shift if @ws_message_log.size > 200
|
|
54
|
+
|
|
55
|
+
conn = @ws_connections.find { |c| c[:id] == conn_id }
|
|
56
|
+
if conn
|
|
57
|
+
if direction == 'sent'
|
|
58
|
+
conn[:messages_sent] += 1
|
|
59
|
+
else
|
|
60
|
+
conn[:messages_received] += 1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
register_tool('get_ws_connections',
|
|
68
|
+
'Get active WebSocket connections (faye-websocket, websocket-driver, ActionCable). ' \
|
|
69
|
+
'Shows connection ID, remote address, connected since, and message counts') do
|
|
70
|
+
conns = @ws_lock.synchronize { @ws_connections.dup }
|
|
71
|
+
|
|
72
|
+
# Also try to auto-detect ActionCable connections
|
|
73
|
+
if defined?(::ActionCable)
|
|
74
|
+
begin
|
|
75
|
+
server = ::ActionCable.server
|
|
76
|
+
if server && server.respond_to?(:connections)
|
|
77
|
+
server.connections.each do |conn|
|
|
78
|
+
next if conns.any? { |c| c[:id] == conn.object_id }
|
|
79
|
+
conns << {
|
|
80
|
+
id: conn.object_id,
|
|
81
|
+
remote_addr: conn.respond_to?(:env) ? (conn.env['REMOTE_ADDR'] rescue 'unknown') : 'unknown',
|
|
82
|
+
channel: 'ActionCable',
|
|
83
|
+
connected_since: nil,
|
|
84
|
+
messages_sent: conn.respond_to?(:transmissions) ? conn.transmissions : nil,
|
|
85
|
+
messages_received: nil,
|
|
86
|
+
source: 'actioncable'
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
rescue => e
|
|
91
|
+
conns << { source: 'actioncable', error: e.message }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if conns.empty?
|
|
96
|
+
next {
|
|
97
|
+
message: 'No WebSocket connections tracked. Register with DebugAgent.register_ws_server ' \
|
|
98
|
+
'or DebugAgent.track_ws_connection. Auto-detects ActionCable if loaded.',
|
|
99
|
+
total: 0
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
{ total: conns.size, connections: conns }
|
|
104
|
+
rescue => e
|
|
105
|
+
{ error: e.message }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
register_tool('get_ws_stats',
|
|
109
|
+
'Get WebSocket statistics: total connections, total messages, ' \
|
|
110
|
+
'messages per connection, uptime') do
|
|
111
|
+
conns = @ws_lock.synchronize { @ws_connections.dup }
|
|
112
|
+
msgs = @ws_lock.synchronize { @ws_message_log.dup }
|
|
113
|
+
|
|
114
|
+
if conns.empty? && msgs.empty?
|
|
115
|
+
next { total_connections: 0, total_messages: 0, message: 'No WebSocket activity recorded.' }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
sent = conns.sum { |c| c[:messages_sent] || 0 }
|
|
119
|
+
received = conns.sum { |c| c[:messages_received] || 0 }
|
|
120
|
+
total_bytes = msgs.sum { |m| m[:size] || 0 }
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
total_connections: conns.size,
|
|
124
|
+
total_messages_sent: sent,
|
|
125
|
+
total_messages_received: received,
|
|
126
|
+
total_messages: sent + received,
|
|
127
|
+
total_bytes: total_bytes,
|
|
128
|
+
avg_messages_per_connection: conns.empty? ? 0 : ((sent + received).to_f / conns.size).round(2),
|
|
129
|
+
registered_servers: ws_servers.keys,
|
|
130
|
+
recent_messages: msgs.reverse.first(20)
|
|
131
|
+
}
|
|
132
|
+
rescue => e
|
|
133
|
+
{ error: e.message }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
register_tool('get_ws_channels',
|
|
137
|
+
'Get WebSocket channels with subscriber counts (ActionCable channels or custom pub/sub)') do
|
|
138
|
+
channels = []
|
|
139
|
+
|
|
140
|
+
# Auto-detect ActionCable channels
|
|
141
|
+
if defined?(::ActionCable::Channel::Base)
|
|
142
|
+
begin
|
|
143
|
+
# Look for channel subclasses
|
|
144
|
+
channel_classes = []
|
|
145
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
146
|
+
if klass < ::ActionCable::Channel::Base && klass != ::ActionCable::Channel::Base
|
|
147
|
+
channel_classes << klass
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
channel_classes.uniq.each do |klass|
|
|
152
|
+
channels << {
|
|
153
|
+
name: klass.name,
|
|
154
|
+
source: 'actioncable',
|
|
155
|
+
subscribers: 0 # ActionCable doesn't expose per-channel counts easily
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
rescue => e
|
|
159
|
+
channels << { source: 'actioncable', error: e.message }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Track connections grouped by channel from our own registry
|
|
164
|
+
@ws_lock.synchronize do
|
|
165
|
+
by_channel = @ws_connections.group_by { |c| c[:channel] || 'default' }
|
|
166
|
+
by_channel.each do |channel, conns|
|
|
167
|
+
existing = channels.find { |ch| ch[:name] == channel }
|
|
168
|
+
if existing
|
|
169
|
+
existing[:subscribers] = conns.size
|
|
170
|
+
else
|
|
171
|
+
channels << { name: channel, source: 'tracked', subscribers: conns.size }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
if channels.empty?
|
|
177
|
+
next {
|
|
178
|
+
message: 'No WebSocket channels found. Define ActionCable channels or use ' \
|
|
179
|
+
'DebugAgent.track_ws_connection with a channel name.',
|
|
180
|
+
total: 0
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
{ total: channels.size, channels: channels }
|
|
185
|
+
rescue => e
|
|
186
|
+
{ error: e.message }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -83,72 +83,72 @@ module DebugAgent
|
|
|
83
83
|
|
|
84
84
|
def stream_request(path, body, handler)
|
|
85
85
|
uri = URI(@cfg.base_url + path)
|
|
86
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
87
|
-
http.use_ssl = uri.scheme == 'https'
|
|
88
|
-
http.read_timeout = @cfg.timeout_seconds
|
|
89
86
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
response = http.request(request)
|
|
96
|
-
|
|
97
|
-
if response.code.to_i >= 400
|
|
98
|
-
raise RetriableError.new(response.code.to_i, "HTTP #{response.code}: #{response.body}")
|
|
99
|
-
end
|
|
87
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: @cfg.timeout_seconds) do |http|
|
|
88
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
89
|
+
request['Authorization'] = "Bearer #{@cfg.api_key}"
|
|
90
|
+
request['Content-Type'] = 'application/json'
|
|
91
|
+
request.body = JSON.generate(body)
|
|
100
92
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
93
|
+
tool_call_map = {}
|
|
94
|
+
finish_reason = nil
|
|
95
|
+
usage = nil
|
|
104
96
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
data_str = line[6..]
|
|
110
|
-
next if data_str.strip == '[DONE]'
|
|
111
|
-
|
|
112
|
-
begin
|
|
113
|
-
parsed = JSON.parse(data_str)
|
|
114
|
-
rescue JSON::ParserError
|
|
115
|
-
next
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
if parsed['usage'] && parsed['usage']['prompt_tokens']
|
|
119
|
-
usage = parsed['usage']
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
choices = parsed['choices'] || []
|
|
123
|
-
next if choices.empty?
|
|
124
|
-
|
|
125
|
-
choice = choices[0]
|
|
126
|
-
delta = choice['delta'] || {}
|
|
127
|
-
|
|
128
|
-
if delta['content'] && !delta['content'].empty?
|
|
129
|
-
handler.on_content(delta['content'])
|
|
97
|
+
http.request(request) do |response|
|
|
98
|
+
if response.code.to_i >= 400
|
|
99
|
+
err_body = response.read_body
|
|
100
|
+
raise RetriableError.new(response.code.to_i, "HTTP #{response.code}: #{err_body}")
|
|
130
101
|
end
|
|
131
102
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
103
|
+
response.read_body do |chunk|
|
|
104
|
+
chunk.split("\n").each do |line|
|
|
105
|
+
next unless line.start_with?('data: ')
|
|
106
|
+
|
|
107
|
+
data_str = line[6..]
|
|
108
|
+
next if data_str.strip == '[DONE]'
|
|
109
|
+
|
|
110
|
+
begin
|
|
111
|
+
parsed = JSON.parse(data_str)
|
|
112
|
+
rescue JSON::ParserError
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if parsed['usage'] && parsed['usage']['prompt_tokens']
|
|
117
|
+
usage = parsed['usage']
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
choices = parsed['choices'] || []
|
|
121
|
+
next if choices.empty?
|
|
122
|
+
|
|
123
|
+
choice = choices[0]
|
|
124
|
+
delta = choice['delta'] || {}
|
|
125
|
+
|
|
126
|
+
if delta['content'] && !delta['content'].empty?
|
|
127
|
+
handler.on_content(delta['content'])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if delta['tool_calls']
|
|
131
|
+
delta['tool_calls'].each do |tc|
|
|
132
|
+
idx = tc['index'] || 0
|
|
133
|
+
tool_call_map[idx] ||= { 'id' => '', 'type' => 'function', 'function' => { 'name' => '', 'arguments' => '' } }
|
|
134
|
+
entry = tool_call_map[idx]
|
|
135
|
+
entry['id'] = tc['id'] if tc['id']
|
|
136
|
+
entry['type'] = tc['type'] if tc['type']
|
|
137
|
+
fn = tc['function'] || {}
|
|
138
|
+
entry['function']['name'] += fn['name'] if fn['name']
|
|
139
|
+
entry['function']['arguments'] += fn['arguments'] if fn['arguments']
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
finish_reason = choice['finish_reason'] if choice['finish_reason']
|
|
142
144
|
end
|
|
143
145
|
end
|
|
144
|
-
|
|
145
|
-
finish_reason = choice['finish_reason'] if choice['finish_reason']
|
|
146
146
|
end
|
|
147
|
-
end
|
|
148
147
|
|
|
149
|
-
|
|
148
|
+
tool_calls = tool_call_map.keys.sort.map { |k| tool_call_map[k] }.select { |tc| tc['function']['name'] && !tc['function']['name'].empty? }
|
|
150
149
|
|
|
151
|
-
|
|
150
|
+
handler.on_complete(tool_calls, finish_reason, usage)
|
|
151
|
+
end
|
|
152
152
|
end
|
|
153
153
|
|
|
154
154
|
# ==================== Non-Streaming POST with retry ====================
|
data/lib/debug_agent/version.rb
CHANGED
data/lib/debug_agent.rb
CHANGED
|
@@ -30,6 +30,11 @@ require_relative 'debug_agent/inspectors/metrics'
|
|
|
30
30
|
require_relative 'debug_agent/inspectors/active_record_stats'
|
|
31
31
|
require_relative 'debug_agent/inspectors/faraday'
|
|
32
32
|
require_relative 'debug_agent/inspectors/concurrent'
|
|
33
|
+
require_relative 'debug_agent/inspectors/security'
|
|
34
|
+
require_relative 'debug_agent/inspectors/health'
|
|
35
|
+
require_relative 'debug_agent/inspectors/scheduler'
|
|
36
|
+
require_relative 'debug_agent/inspectors/error_tracking'
|
|
37
|
+
require_relative 'debug_agent/inspectors/websocket'
|
|
33
38
|
|
|
34
39
|
module DebugAgent
|
|
35
40
|
class Error < StandardError; end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: debug-agent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ggcode
|
|
@@ -168,8 +168,10 @@ files:
|
|
|
168
168
|
- lib/debug_agent/inspectors/cache.rb
|
|
169
169
|
- lib/debug_agent/inspectors/concurrent.rb
|
|
170
170
|
- lib/debug_agent/inspectors/core_ext.rb
|
|
171
|
+
- lib/debug_agent/inspectors/error_tracking.rb
|
|
171
172
|
- lib/debug_agent/inspectors/faraday.rb
|
|
172
173
|
- lib/debug_agent/inspectors/gc.rb
|
|
174
|
+
- lib/debug_agent/inspectors/health.rb
|
|
173
175
|
- lib/debug_agent/inspectors/http_client.rb
|
|
174
176
|
- lib/debug_agent/inspectors/http_tracker.rb
|
|
175
177
|
- lib/debug_agent/inspectors/logging.rb
|
|
@@ -181,9 +183,12 @@ files:
|
|
|
181
183
|
- lib/debug_agent/inspectors/redis.rb
|
|
182
184
|
- lib/debug_agent/inspectors/routes.rb
|
|
183
185
|
- lib/debug_agent/inspectors/runtime.rb
|
|
186
|
+
- lib/debug_agent/inspectors/scheduler.rb
|
|
187
|
+
- lib/debug_agent/inspectors/security.rb
|
|
184
188
|
- lib/debug_agent/inspectors/sidekiq.rb
|
|
185
189
|
- lib/debug_agent/inspectors/system.rb
|
|
186
190
|
- lib/debug_agent/inspectors/threads.rb
|
|
191
|
+
- lib/debug_agent/inspectors/websocket.rb
|
|
187
192
|
- lib/debug_agent/llm_client.rb
|
|
188
193
|
- lib/debug_agent/middleware.rb
|
|
189
194
|
- lib/debug_agent/system_prompt_builder.rb
|