debug-agent 0.2.6
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 +7 -0
- data/README.md +144 -0
- data/lib/debug_agent/chat_page.rb +575 -0
- data/lib/debug_agent/chat_session.rb +47 -0
- data/lib/debug_agent/config.rb +34 -0
- data/lib/debug_agent/context_compressor.rb +159 -0
- data/lib/debug_agent/engine.rb +162 -0
- data/lib/debug_agent/inspectors/gc.rb +90 -0
- data/lib/debug_agent/inspectors/http_tracker.rb +70 -0
- data/lib/debug_agent/inspectors/object_space.rb +74 -0
- data/lib/debug_agent/inspectors/process_info.rb +61 -0
- data/lib/debug_agent/inspectors/routes.rb +114 -0
- data/lib/debug_agent/inspectors/runtime.rb +81 -0
- data/lib/debug_agent/inspectors/system.rb +61 -0
- data/lib/debug_agent/inspectors/threads.rb +67 -0
- data/lib/debug_agent/llm_client.rb +221 -0
- data/lib/debug_agent/middleware.rb +127 -0
- data/lib/debug_agent/system_prompt_builder.rb +97 -0
- data/lib/debug_agent/tool_registry.rb +97 -0
- data/lib/debug_agent/version.rb +3 -0
- data/lib/debug_agent.rb +34 -0
- metadata +106 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module DebugAgent
|
|
4
|
+
CompressionResult = Struct.new(:original_tokens, :compressed_tokens, :removed_rounds, :strategy, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
class ContextCompressor
|
|
7
|
+
def initialize(llm, model, temperature, max_context_tokens, recent_rounds_to_keep = 3)
|
|
8
|
+
@llm = llm
|
|
9
|
+
@model = model
|
|
10
|
+
@temperature = temperature
|
|
11
|
+
@max_context_tokens = max_context_tokens
|
|
12
|
+
@recent_rounds_to_keep = recent_rounds_to_keep
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Trigger compression when token usage exceeds 75% of context window
|
|
16
|
+
def needs_compression?(current_tokens)
|
|
17
|
+
current_tokens > (@max_context_tokens * 0.75).to_i
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def compress(session)
|
|
21
|
+
original_tokens = session.current_context_tokens
|
|
22
|
+
return nil unless needs_compression?(original_tokens)
|
|
23
|
+
|
|
24
|
+
rounds = identify_rounds(session.messages)
|
|
25
|
+
|
|
26
|
+
keep_count = [@recent_rounds_to_keep, rounds.size - 1].min
|
|
27
|
+
return nil if keep_count < 1
|
|
28
|
+
|
|
29
|
+
summarize_count = rounds.size - keep_count
|
|
30
|
+
|
|
31
|
+
to_summarize = rounds.first(summarize_count).flatten
|
|
32
|
+
to_keep = rounds.drop(summarize_count).flatten
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
summary = summarize_with_llm(to_summarize)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
summary = fallback_truncate(to_summarize)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
compressed = [
|
|
41
|
+
{ 'role' => 'system', 'content' => "[Previous conversation summary — #{summarize_count} rounds compressed]\n\n#{summary}" }
|
|
42
|
+
] + to_keep
|
|
43
|
+
|
|
44
|
+
compressed_tokens = estimate_tokens(compressed)
|
|
45
|
+
session.replace_messages(compressed)
|
|
46
|
+
|
|
47
|
+
CompressionResult.new(
|
|
48
|
+
original_tokens: original_tokens,
|
|
49
|
+
compressed_tokens: compressed_tokens,
|
|
50
|
+
removed_rounds: summarize_count,
|
|
51
|
+
strategy: "LLM summarized #{summarize_count} rounds"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def summarize_with_llm(old_messages)
|
|
58
|
+
conversation_text = ''
|
|
59
|
+
old_messages.each do |msg|
|
|
60
|
+
case msg['role']
|
|
61
|
+
when 'user'
|
|
62
|
+
conversation_text << "[User] #{msg['content']}\n\n"
|
|
63
|
+
when 'assistant'
|
|
64
|
+
conversation_text << "[Assistant] #{msg['content']}\n\n" if msg['content']
|
|
65
|
+
(msg['tool_calls'] || []).each do |tc|
|
|
66
|
+
fn = tc['function'] || {}
|
|
67
|
+
conversation_text << "[Tool Call] #{fn['name']}(#{fn['arguments']})\n\n"
|
|
68
|
+
end
|
|
69
|
+
when 'tool'
|
|
70
|
+
content = msg['content'].to_s
|
|
71
|
+
content = content[0..2000] + '...[truncated]' if content.length > 2000
|
|
72
|
+
conversation_text << "[Tool Result] #{content}\n\n"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
prompt = <<~PROMPT
|
|
77
|
+
You are a conversation summarizer for a Ruby debugging assistant.
|
|
78
|
+
Summarize the KEY diagnostic findings from the conversation below concisely.
|
|
79
|
+
|
|
80
|
+
Focus on preserving:
|
|
81
|
+
- Problems investigated and their root causes (if found)
|
|
82
|
+
- Key tool results: actual numbers, statuses, error messages, configuration values
|
|
83
|
+
- Recommendations or fixes already suggested
|
|
84
|
+
- Any unresolved issues or follow-up actions pending
|
|
85
|
+
|
|
86
|
+
Rules:
|
|
87
|
+
- Be concise but preserve ALL important data points
|
|
88
|
+
- Use bullet points
|
|
89
|
+
- Do NOT include full JSON dumps
|
|
90
|
+
- Keep it under 600 words
|
|
91
|
+
PROMPT
|
|
92
|
+
|
|
93
|
+
response = @llm.chat(
|
|
94
|
+
[
|
|
95
|
+
{ 'role' => 'system', 'content' => prompt },
|
|
96
|
+
{ 'role' => 'user', 'content' => "Conversation to summarize:\n\n#{conversation_text}" }
|
|
97
|
+
],
|
|
98
|
+
nil
|
|
99
|
+
)
|
|
100
|
+
response.dig('choices', 0, 'message', 'content') || '(summary unavailable)'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def fallback_truncate(messages)
|
|
104
|
+
sb = +"Previous conversation summary (fallback):\n\n"
|
|
105
|
+
messages.each do |msg|
|
|
106
|
+
if msg['role'] == 'user' && msg['content']
|
|
107
|
+
q = msg['content'].length > 100 ? msg['content'][0..99] + '...' : msg['content']
|
|
108
|
+
sb << "- User asked: #{q}\n"
|
|
109
|
+
end
|
|
110
|
+
if msg['role'] == 'assistant' && msg['tool_calls']
|
|
111
|
+
msg['tool_calls'].each { |tc| sb << "- Called tool: #{tc.dig('function', 'name')}\n" }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
sb
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def identify_rounds(messages)
|
|
118
|
+
rounds = []
|
|
119
|
+
current = []
|
|
120
|
+
has_assistant = false
|
|
121
|
+
|
|
122
|
+
messages.each do |msg|
|
|
123
|
+
case msg['role']
|
|
124
|
+
when 'user'
|
|
125
|
+
if current.any?
|
|
126
|
+
rounds << current
|
|
127
|
+
current = []
|
|
128
|
+
has_assistant = false
|
|
129
|
+
end
|
|
130
|
+
current << msg
|
|
131
|
+
when 'assistant'
|
|
132
|
+
if has_assistant
|
|
133
|
+
rounds << current
|
|
134
|
+
current = []
|
|
135
|
+
has_assistant = false
|
|
136
|
+
end
|
|
137
|
+
current << msg
|
|
138
|
+
has_assistant = true
|
|
139
|
+
else
|
|
140
|
+
current << msg
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
rounds << current if current.any?
|
|
144
|
+
rounds
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def estimate_tokens(messages)
|
|
148
|
+
chars = 0
|
|
149
|
+
messages.each do |msg|
|
|
150
|
+
chars += (msg['content'] || '').length
|
|
151
|
+
(msg['tool_calls'] || []).each do |tc|
|
|
152
|
+
fn = tc['function'] || {}
|
|
153
|
+
chars += (fn['name'] || '').length + (fn['arguments'] || '').length
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
chars / 4
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module DebugAgent
|
|
4
|
+
# StreamHandler implementation used internally by the engine
|
|
5
|
+
class EngineStreamHandler < StreamHandler
|
|
6
|
+
attr_reader :tool_calls, :usage, :had_error, :content
|
|
7
|
+
|
|
8
|
+
def initialize(callback)
|
|
9
|
+
@callback = callback
|
|
10
|
+
@tool_calls = []
|
|
11
|
+
@usage = nil
|
|
12
|
+
@had_error = false
|
|
13
|
+
@content = +''
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def on_content(chunk)
|
|
17
|
+
@content << chunk
|
|
18
|
+
@callback.on_content(chunk)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def on_complete(tool_calls, finish_reason, usage)
|
|
22
|
+
@tool_calls = tool_calls
|
|
23
|
+
@usage = usage
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def on_error(error)
|
|
27
|
+
@had_error = true
|
|
28
|
+
@callback.on_error("LLM API error: #{error.message}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class DebugEngine
|
|
33
|
+
attr_reader :tools, :system_prompt
|
|
34
|
+
|
|
35
|
+
def initialize(config = nil)
|
|
36
|
+
@config = config || Config.from_env
|
|
37
|
+
@llm = LLMClient.new(@config.llm)
|
|
38
|
+
@tools = REGISTRY
|
|
39
|
+
|
|
40
|
+
@prompt_builder = SystemPromptBuilder.new(@tools)
|
|
41
|
+
@system_prompt = @prompt_builder.build
|
|
42
|
+
|
|
43
|
+
@context_compressor = ContextCompressor.new(
|
|
44
|
+
@llm, @config.llm.model, @config.llm.temperature, @config.llm.context_window_tokens
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@sessions = {}
|
|
48
|
+
@mutex = Mutex.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def chat(message, session_id = 'default', callback = nil)
|
|
52
|
+
callback ||= ChatCallback.new
|
|
53
|
+
|
|
54
|
+
session = get_or_create_session(session_id)
|
|
55
|
+
session.add_message({ 'role' => 'user', 'content' => message })
|
|
56
|
+
|
|
57
|
+
run_tool_loop(session, callback)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def clear_session(session_id = 'default')
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
session = @sessions[session_id]
|
|
63
|
+
session&.clear
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def get_or_create_session(session_id)
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
@sessions[session_id] ||= ChatSession.new(session_id)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def run_tool_loop(session, cb)
|
|
76
|
+
max_rounds = @config.llm.max_tool_rounds
|
|
77
|
+
|
|
78
|
+
max_rounds.times do |round_num|
|
|
79
|
+
# Context compression
|
|
80
|
+
if round_num > 0 && @context_compressor.needs_compression?(session.current_context_tokens)
|
|
81
|
+
result = @context_compressor.compress(session)
|
|
82
|
+
if result
|
|
83
|
+
cb.on_content("\n\n> [Context auto-compressed: #{result.original_tokens} -> ~#{result.compressed_tokens} tokens (#{result.strategy})]\n\n")
|
|
84
|
+
cb.on_context_compressed(result.original_tokens, result.compressed_tokens, result.removed_rounds)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
messages = [{ 'role' => 'system', 'content' => @system_prompt }] + session.messages
|
|
89
|
+
tool_schemas = @tools.all_schemas
|
|
90
|
+
|
|
91
|
+
handler = EngineStreamHandler.new(cb)
|
|
92
|
+
@llm.chat_stream_raw(messages, tool_schemas, 'auto', handler)
|
|
93
|
+
|
|
94
|
+
return if handler.had_error
|
|
95
|
+
|
|
96
|
+
session.record_token_usage(handler.usage) if handler.usage
|
|
97
|
+
|
|
98
|
+
if handler.tool_calls.empty?
|
|
99
|
+
# After tool calls, if LLM returns empty content, prompt it to summarize
|
|
100
|
+
if handler.content.strip.empty? && round_num > 0
|
|
101
|
+
session.add_message({ 'role' => 'assistant', 'content' => '' })
|
|
102
|
+
session.add_message({
|
|
103
|
+
'role' => 'user',
|
|
104
|
+
'content' => 'You called tools but did not provide any analysis. ' \
|
|
105
|
+
'Please summarize the key findings from the tool results above and ' \
|
|
106
|
+
'provide actionable recommendations.'
|
|
107
|
+
})
|
|
108
|
+
next
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Final answer
|
|
112
|
+
session.add_message({ 'role' => 'assistant', 'content' => handler.content })
|
|
113
|
+
cb.on_complete
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Execute tool calls
|
|
118
|
+
session.add_message({
|
|
119
|
+
'role' => 'assistant',
|
|
120
|
+
'content' => handler.content,
|
|
121
|
+
'tool_calls' => handler.tool_calls
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
handler.tool_calls.each do |tc|
|
|
125
|
+
tool_name = tc['function']['name']
|
|
126
|
+
args = {}
|
|
127
|
+
begin
|
|
128
|
+
args = JSON.parse(tc['function']['arguments'] || '{}')
|
|
129
|
+
rescue JSON::ParserError
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
cb.on_tool_start(tool_name, tc['function']['arguments'])
|
|
133
|
+
|
|
134
|
+
result = @tools.execute(tool_name, args)
|
|
135
|
+
result_str = JSON.generate(result)
|
|
136
|
+
result_str = result_str[0..12_000] if result_str.length > 12_000
|
|
137
|
+
|
|
138
|
+
cb.on_tool_result(tool_name, result_str)
|
|
139
|
+
session.add_message({
|
|
140
|
+
'role' => 'tool',
|
|
141
|
+
'tool_call_id' => tc['id'],
|
|
142
|
+
'content' => result_str
|
|
143
|
+
})
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Max rounds — force final summary
|
|
148
|
+
final_messages = [{ 'role' => 'system', 'content' => @system_prompt }] + session.messages
|
|
149
|
+
final_messages << {
|
|
150
|
+
'role' => 'system',
|
|
151
|
+
'content' => 'You have reached the maximum number of tool-calling rounds. ' \
|
|
152
|
+
'Based on all the diagnostic data you have gathered so far, ' \
|
|
153
|
+
'provide a comprehensive analysis and actionable recommendations NOW. ' \
|
|
154
|
+
'Do not attempt to call more tools.'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
handler = EngineStreamHandler.new(cb)
|
|
158
|
+
@llm.chat_stream_raw(final_messages, [], 'none', handler)
|
|
159
|
+
cb.on_complete
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require 'objspace'
|
|
2
|
+
|
|
3
|
+
module DebugAgent
|
|
4
|
+
register_tool('get_gc_stats',
|
|
5
|
+
'Get detailed GC statistics: count, heap pages, slots, total allocated objects') do
|
|
6
|
+
stats = GC.stat
|
|
7
|
+
{
|
|
8
|
+
count: stats[:count],
|
|
9
|
+
major_gc_count: stats[:major_gc_count],
|
|
10
|
+
minor_gc_count: stats[:minor_gc_count],
|
|
11
|
+
total_allocated_objects: stats[:total_allocated_objects],
|
|
12
|
+
total_freed_objects: stats[:total_freed_objects],
|
|
13
|
+
heap_allocation_pages: stats[:heap_length],
|
|
14
|
+
heap_eden_pages: stats[:heap_eden_pages],
|
|
15
|
+
heap_tomb_pages: stats[:heap_tomb_pages],
|
|
16
|
+
total_slots: stats[:heap_length] * 512,
|
|
17
|
+
live_slots: stats[:heap_live_slots],
|
|
18
|
+
free_slots: stats[:heap_free_slots],
|
|
19
|
+
old_objects: stats[:old_objects],
|
|
20
|
+
old_objects_limit: stats[:old_objects_limit],
|
|
21
|
+
malloc_increase_bytes: stats[:malloc_increase_bytes],
|
|
22
|
+
malloc_increase_bytes_limit: stats[:malloc_increase_bytes_limit]
|
|
23
|
+
}
|
|
24
|
+
rescue => e
|
|
25
|
+
{ error: e.message }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
register_tool('get_gc_profiler',
|
|
29
|
+
'Get GC::Profiler data if profiling is enabled (GC timing details)') do
|
|
30
|
+
if defined?(GC::Profiler)
|
|
31
|
+
raw_data = GC::Profiler.raw_data
|
|
32
|
+
total_time = GC::Profiler.total_time
|
|
33
|
+
if raw_data && !raw_data.empty?
|
|
34
|
+
{
|
|
35
|
+
enabled: true,
|
|
36
|
+
total_gc_time_seconds: total_time.round(6),
|
|
37
|
+
gc_count: raw_data.size,
|
|
38
|
+
entries: raw_data.map do |entry|
|
|
39
|
+
{
|
|
40
|
+
gc_time: entry[:GC_TIME]&.round(6),
|
|
41
|
+
gc_invoke_time: entry[:GC_INVOKE_TIME]&.round(6),
|
|
42
|
+
heap_use_pages: entry[:HEAP_USE_PAGES],
|
|
43
|
+
heap_live_objects: entry[:HEAP_LIVE_OBJECTS],
|
|
44
|
+
heap_free_objects: entry[:HEAP_FREE_OBJECTS],
|
|
45
|
+
heap_total_objects: entry[:HEAP_TOTAL_OBJECTS],
|
|
46
|
+
gc_mark_time: entry[:GC_MARK_TIME]&.round(6),
|
|
47
|
+
gc_sweep_time: entry[:GC_SWEEP_TIME]&.round(6)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
}
|
|
51
|
+
else
|
|
52
|
+
{
|
|
53
|
+
enabled: true,
|
|
54
|
+
message: 'GC::Profiler is available but has no data. Call GC::Profiler.enable to start collecting.',
|
|
55
|
+
total_gc_time_seconds: 0
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
{ enabled: false, message: 'GC::Profiler is not available on this Ruby implementation' }
|
|
60
|
+
end
|
|
61
|
+
rescue => e
|
|
62
|
+
{ error: e.message }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
register_tool('force_gc',
|
|
66
|
+
'Trigger a full garbage collection (GC.start with full_mark) and show before/after comparison') do
|
|
67
|
+
before_stats = GC.stat
|
|
68
|
+
before_objects = ObjectSpace.count_objects.values.sum
|
|
69
|
+
|
|
70
|
+
GC.start(full_mark: true)
|
|
71
|
+
GC.start(full_mark: true) # Second call to compact and finalize
|
|
72
|
+
|
|
73
|
+
after_stats = GC.stat
|
|
74
|
+
after_objects = ObjectSpace.count_objects.values.sum
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
triggered: true,
|
|
78
|
+
objects_before: before_objects,
|
|
79
|
+
objects_after: after_objects,
|
|
80
|
+
freed_objects: before_objects - after_objects,
|
|
81
|
+
gc_count_before: before_stats[:count],
|
|
82
|
+
gc_count_after: after_stats[:count],
|
|
83
|
+
live_slots_before: before_stats[:heap_live_slots],
|
|
84
|
+
live_slots_after: after_stats[:heap_live_slots],
|
|
85
|
+
freed_slots: before_stats[:heap_live_slots] - after_stats[:heap_live_slots]
|
|
86
|
+
}
|
|
87
|
+
rescue => e
|
|
88
|
+
{ error: e.message }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
require 'thread'
|
|
3
|
+
|
|
4
|
+
module DebugAgent
|
|
5
|
+
MAX_REQUESTS = 500
|
|
6
|
+
@request_buffer = []
|
|
7
|
+
@buffer_lock = Mutex.new
|
|
8
|
+
|
|
9
|
+
module HttpRequestTracker
|
|
10
|
+
def self.record(method, path, status, duration_ms, client = '')
|
|
11
|
+
DebugAgent.instance_variable_get(:@buffer_lock).synchronize do
|
|
12
|
+
buffer = DebugAgent.instance_variable_get(:@request_buffer)
|
|
13
|
+
buffer << {
|
|
14
|
+
timestamp: Time.now.iso8601,
|
|
15
|
+
method: method,
|
|
16
|
+
path: path,
|
|
17
|
+
status: status,
|
|
18
|
+
duration_ms: duration_ms.round(2),
|
|
19
|
+
client: client
|
|
20
|
+
}
|
|
21
|
+
buffer.shift if buffer.size > MAX_REQUESTS
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.all
|
|
26
|
+
DebugAgent.instance_variable_get(:@buffer_lock).synchronize do
|
|
27
|
+
DebugAgent.instance_variable_get(:@request_buffer).dup
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
register_tool('get_recent_requests', 'Get recent HTTP requests from ring buffer') do |limit: 50|
|
|
33
|
+
reqs = HttpRequestTracker.all
|
|
34
|
+
reqs = reqs.last(limit) if limit
|
|
35
|
+
{
|
|
36
|
+
total: HttpRequestTracker.all.size,
|
|
37
|
+
requests: reqs.reverse
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
register_tool('get_error_requests', 'Get error requests (4xx/5xx)') do
|
|
42
|
+
reqs = HttpRequestTracker.all.select { |r| r[:status] >= 400 }
|
|
43
|
+
{
|
|
44
|
+
count: reqs.size,
|
|
45
|
+
requests: reqs.sort_by { |r| -r[:duration_ms] }
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
register_tool('get_request_stats', 'Get HTTP request stats: P50/P95/P99 latency, error rate') do
|
|
50
|
+
reqs = HttpRequestTracker.all
|
|
51
|
+
next({ message: 'No requests recorded yet' }) if reqs.empty?
|
|
52
|
+
|
|
53
|
+
durations = reqs.map { |r| r[:duration_ms] }.sort
|
|
54
|
+
n = durations.size
|
|
55
|
+
errors = reqs.count { |r| r[:status] >= 400 }
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
total_requests: n,
|
|
59
|
+
error_count: errors,
|
|
60
|
+
error_rate: format('%.1f%%', errors.to_f / n * 100),
|
|
61
|
+
latency_ms: {
|
|
62
|
+
min: durations[0].round(2),
|
|
63
|
+
p50: durations[(n * 0.5).to_i].round(2),
|
|
64
|
+
p95: durations[(n * 0.95).to_i].round(2),
|
|
65
|
+
p99: durations[(n * 0.99).to_i].round(2),
|
|
66
|
+
max: durations[-1].round(2)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require 'objspace'
|
|
2
|
+
|
|
3
|
+
module DebugAgent
|
|
4
|
+
register_tool('get_object_space_stats',
|
|
5
|
+
'Get ObjectSpace.count_objects summary by type (T_STRING, T_ARRAY, etc.)') do
|
|
6
|
+
counts = ObjectSpace.count_objects
|
|
7
|
+
total = counts[:TOTAL] || counts.values.sum
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
total_objects: total,
|
|
11
|
+
free_slots: counts[:FREE] || 0,
|
|
12
|
+
by_type: counts.reject { |k, _| k == :TOTAL || k == :FREE }
|
|
13
|
+
.sort_by { |_, v| -v }
|
|
14
|
+
.map { |type, count| { type: type.to_s, count: count } }
|
|
15
|
+
}
|
|
16
|
+
rescue => e
|
|
17
|
+
{ error: e.message }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
register_tool('get_memory_size',
|
|
21
|
+
'Get total memory size of all live objects (ObjectSpace.memsize_of_all)') do
|
|
22
|
+
total_bytes = ObjectSpace.memsize_of_all
|
|
23
|
+
|
|
24
|
+
# Also get per-type breakdown
|
|
25
|
+
type_sizes = {}
|
|
26
|
+
ObjectSpace.count_objects.each do |type, count|
|
|
27
|
+
next if type == :TOTAL || type == :FREE || count == 0
|
|
28
|
+
begin
|
|
29
|
+
size = case type
|
|
30
|
+
when :T_STRING then ObjectSpace.memsize_of_all(String)
|
|
31
|
+
when :T_ARRAY then ObjectSpace.memsize_of_all(Array)
|
|
32
|
+
when :T_HASH then ObjectSpace.memsize_of_all(Hash)
|
|
33
|
+
when :T_OBJECT then ObjectSpace.memsize_of_all(Object)
|
|
34
|
+
else 0
|
|
35
|
+
end
|
|
36
|
+
type_sizes[type.to_s] = size if size > 0
|
|
37
|
+
rescue
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
total_bytes: total_bytes,
|
|
43
|
+
total_mb: (total_bytes / 1024.0 / 1024.0).round(2),
|
|
44
|
+
total_kb: (total_bytes / 1024.0).round(2),
|
|
45
|
+
top_type_sizes: type_sizes.sort_by { |_, v| -v }.first(10).to_h
|
|
46
|
+
}
|
|
47
|
+
rescue => e
|
|
48
|
+
{ error: e.message }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
register_tool('get_object_count_by_class',
|
|
52
|
+
'Get top N classes by instance count using ObjectSpace.each_object') do |top_n: 20|
|
|
53
|
+
counts = Hash.new(0)
|
|
54
|
+
|
|
55
|
+
ObjectSpace.each_object do |obj|
|
|
56
|
+
begin
|
|
57
|
+
klass = obj.class
|
|
58
|
+
name = klass.name || klass.to_s
|
|
59
|
+
counts[name] += 1
|
|
60
|
+
rescue
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
top = counts.sort_by { |_, v| -v }.first(top_n)
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
total_classes: counts.size,
|
|
68
|
+
total_instances: counts.values.sum,
|
|
69
|
+
top_classes: top.map { |name, count| { class: name, count: count } }
|
|
70
|
+
}
|
|
71
|
+
rescue => e
|
|
72
|
+
{ error: e.message }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require 'etc'
|
|
2
|
+
|
|
3
|
+
module DebugAgent
|
|
4
|
+
register_tool('get_process_info',
|
|
5
|
+
'Get process info: PID, PPID, platform, Ruby version, uptime') do
|
|
6
|
+
rss = `ps -o rss= -p #{Process.pid}`.to_i
|
|
7
|
+
start_time = DebugAgent::PROCESS_START_TIME
|
|
8
|
+
uptime_seconds = Time.now - start_time
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
pid: Process.pid,
|
|
12
|
+
ppid: Process.ppid,
|
|
13
|
+
uid: Process.uid,
|
|
14
|
+
gid: Process.gid,
|
|
15
|
+
user: Etc.getpwuid(Process.uid)&.name,
|
|
16
|
+
platform: RUBY_PLATFORM,
|
|
17
|
+
ruby_version: RUBY_VERSION,
|
|
18
|
+
ruby_engine: RUBY_ENGINE,
|
|
19
|
+
ruby_patchlevel: defined?(RUBY_PATCHLEVEL) ? RUBY_PATCHLEVEL : nil,
|
|
20
|
+
ruby_revision: defined?(RUBY_REVISION) ? RUBY_REVISION.to_s : nil,
|
|
21
|
+
process_name: $0,
|
|
22
|
+
rss_mb: (rss / 1024.0).round(2),
|
|
23
|
+
uptime_seconds: uptime_seconds.round(0),
|
|
24
|
+
uptime_human: format_uptime(uptime_seconds),
|
|
25
|
+
hostname: Socket.gethostname,
|
|
26
|
+
cpu_count: Etc.nprocessors
|
|
27
|
+
}
|
|
28
|
+
rescue => e
|
|
29
|
+
{ error: e.message }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
register_tool('get_cpu_time',
|
|
33
|
+
'Get CPU time: user, system, and total (Process.times)') do
|
|
34
|
+
times = Process.times
|
|
35
|
+
{
|
|
36
|
+
user_cpu_seconds: times.utime.round(4),
|
|
37
|
+
system_cpu_seconds: times.stime.round(4),
|
|
38
|
+
total_cpu_seconds: (times.utime + times.stime).round(4),
|
|
39
|
+
child_user_cpu_seconds: times.cutime.round(4),
|
|
40
|
+
child_system_cpu_seconds: times.cstime.round(4),
|
|
41
|
+
child_total_cpu_seconds: (times.cutime + times.cstime).round(4)
|
|
42
|
+
}
|
|
43
|
+
rescue => e
|
|
44
|
+
{ error: e.message }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Helper method for formatting uptime
|
|
48
|
+
def self.format_uptime(seconds)
|
|
49
|
+
days = (seconds / 86400).to_i
|
|
50
|
+
hours = ((seconds % 86400) / 3600).to_i
|
|
51
|
+
minutes = ((seconds % 3600) / 60).to_i
|
|
52
|
+
secs = (seconds % 60).to_i
|
|
53
|
+
|
|
54
|
+
parts = []
|
|
55
|
+
parts << "#{days}d" if days > 0
|
|
56
|
+
parts << "#{hours}h" if hours > 0 || days > 0
|
|
57
|
+
parts << "#{minutes}m" if minutes > 0 || hours > 0 || days > 0
|
|
58
|
+
parts << "#{secs}s"
|
|
59
|
+
parts.join(' ')
|
|
60
|
+
end
|
|
61
|
+
end
|