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.
@@ -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