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,114 @@
1
+ require 'json'
2
+
3
+ module DebugAgent
4
+ register_tool('get_routes',
5
+ 'Discover Sinatra/Rack routes from the running application') do
6
+ routes = []
7
+ app = DebugAgent.app
8
+
9
+ if app.nil?
10
+ return {
11
+ total: 0,
12
+ routes: [],
13
+ message: 'No application registered with DebugAgent. ' \
14
+ 'Ensure the middleware is properly installed.'
15
+ }
16
+ end
17
+
18
+ app_class = app.is_a?(Class) ? app : app.class
19
+
20
+ # Try Sinatra routes
21
+ if app_class.respond_to?(:routes)
22
+ app_class.routes.each do |method, route_list|
23
+ route_list.each do |route|
24
+ pattern = route[0]
25
+ pattern_str = case pattern
26
+ when Regexp then pattern.source
27
+ else pattern.to_s
28
+ end
29
+ routes << {
30
+ method: method.to_s.upcase,
31
+ pattern: pattern_str,
32
+ file: route[1],
33
+ line: route[2]
34
+ }
35
+ end
36
+ end
37
+ end
38
+
39
+ # Fallback: check Sinatra::Base if nothing found
40
+ if routes.empty? && defined?(Sinatra) && defined?(Sinatra::Base)
41
+ Sinatra::Base.routes.each do |method, route_list|
42
+ route_list.each do |route|
43
+ pattern = route[0]
44
+ pattern_str = case pattern
45
+ when Regexp then pattern.source
46
+ else pattern.to_s
47
+ end
48
+ routes << {
49
+ method: method.to_s.upcase,
50
+ pattern: pattern_str,
51
+ file: route[1],
52
+ line: route[2]
53
+ }
54
+ end
55
+ end
56
+ end
57
+
58
+ {
59
+ total: routes.size,
60
+ routes: routes
61
+ }
62
+ rescue => e
63
+ { error: e.message, total: 0, routes: [] }
64
+ end
65
+
66
+ register_tool('get_middleware_stack',
67
+ 'List Rack middleware stack from the running application') do
68
+ stack = []
69
+ app = DebugAgent.app
70
+
71
+ if app.nil?
72
+ return {
73
+ total: 0,
74
+ middleware: [],
75
+ message: 'No application registered with DebugAgent.'
76
+ }
77
+ end
78
+
79
+ app_class = app.is_a?(Class) ? app : app.class
80
+
81
+ # Try Sinatra/Rack middleware stack
82
+ if app_class.respond_to?(:middleware)
83
+ app_class.middleware.each_with_index do |mw, i|
84
+ klass = mw[0]
85
+ args = mw[1..-1]
86
+ stack << {
87
+ index: i,
88
+ name: klass.respond_to?(:name) ? klass.name : klass.to_s,
89
+ arguments: args.map { |a| a.is_a?(String) ? a : a.inspect }
90
+ }
91
+ end
92
+ end
93
+
94
+ # Also try Sinatra::Base
95
+ if stack.empty? && defined?(Sinatra) && defined?(Sinatra::Base)
96
+ Sinatra::Base.middleware.each_with_index do |mw, i|
97
+ klass = mw[0]
98
+ args = mw[1..-1]
99
+ stack << {
100
+ index: i,
101
+ name: klass.respond_to?(:name) ? klass.name : klass.to_s,
102
+ arguments: args.map { |a| a.is_a?(String) ? a : a.inspect }
103
+ }
104
+ end
105
+ end
106
+
107
+ {
108
+ total: stack.size,
109
+ middleware: stack
110
+ }
111
+ rescue => e
112
+ { error: e.message, total: 0, middleware: [] }
113
+ end
114
+ end
@@ -0,0 +1,81 @@
1
+ require 'objspace'
2
+ require 'thread'
3
+
4
+ module DebugAgent
5
+ register_tool('get_gc_stats', 'Get GC statistics: count, time, live 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
+ live_objects: ObjectSpace.count_objects[:T_OBJECT] || 0,
13
+ heap_pages: stats[:heap_length],
14
+ total_freed_objects: stats[:total_freed_objects]
15
+ }
16
+ end
17
+
18
+ register_tool('get_memory_summary', 'Get process memory usage: RSS, object counts') do
19
+ rss = `ps -o rss= -p #{Process.pid}`.to_i / 1024.0
20
+ counts = ObjectSpace.count_objects
21
+ top_types = counts.sort_by { |_, v| -v }.first(15).to_h
22
+ {
23
+ rss_mb: rss.round(2),
24
+ total_objects: counts.values.sum,
25
+ top_object_types: top_types,
26
+ live_strings: counts[:T_STRING] || 0,
27
+ live_arrays: counts[:T_ARRAY] || 0,
28
+ live_hashes: counts[:T_HASH] || 0
29
+ }
30
+ end
31
+
32
+ register_tool('trigger_gc', 'Trigger GC and show before/after comparison') do
33
+ before = ObjectSpace.count_objects.values.sum
34
+ GC.start
35
+ after = ObjectSpace.count_objects.values.sum
36
+ {
37
+ objects_before: before,
38
+ objects_after: after,
39
+ freed: before - after,
40
+ gc_count: GC.stat[:count]
41
+ }
42
+ end
43
+
44
+ register_tool('get_thread_summary', 'Get thread count and list') do
45
+ threads = Thread.list
46
+ {
47
+ total_threads: threads.size,
48
+ threads: threads.map do |t|
49
+ { name: t.to_s, alive: t.alive?, status: t.status }
50
+ end
51
+ }
52
+ end
53
+
54
+ register_tool('get_runtime_info', 'Get Ruby runtime info: version, platform, PID') do
55
+ {
56
+ ruby_version: RUBY_VERSION,
57
+ ruby_engine: RUBY_ENGINE,
58
+ platform: RUBY_PLATFORM,
59
+ pid: Process.pid,
60
+ process_name: $0,
61
+ load_path_count: $LOAD_PATH.size
62
+ }
63
+ rescue
64
+ {
65
+ ruby_version: RUBY_VERSION,
66
+ ruby_engine: RUBY_ENGINE,
67
+ platform: RUBY_PLATFORM,
68
+ pid: Process.pid
69
+ }
70
+ end
71
+
72
+ register_tool('get_object_allocations',
73
+ 'Get top memory allocation sites using ObjectSpace tracing') do
74
+ # Snapshot current allocation statistics
75
+ stats = ObjectSpace.count_objects
76
+ {
77
+ total_objects: stats.values.sum,
78
+ by_type: stats.sort_by { |_, v| -v }.first(20).map { |k, v| { type: k, count: v } }
79
+ }
80
+ end
81
+ end
@@ -0,0 +1,61 @@
1
+ require 'etc'
2
+
3
+ module DebugAgent
4
+ register_tool('get_system_info', 'Get system info: hostname, CPU, memory, load') do
5
+ {
6
+ hostname: Socket.gethostname,
7
+ os: RUBY_PLATFORM,
8
+ cpu_count: Etc.nprocessors,
9
+ ruby_version: RUBY_VERSION,
10
+ process_count: `ps aux | wc -l`.strip.to_i
11
+ }
12
+ end
13
+
14
+ register_tool('get_disk_usage', 'Get disk usage for current directory') do
15
+ stat = Sys::Filesystem.stat(Dir.pwd) rescue nil
16
+ if stat
17
+ {
18
+ total_gb: (stat.block_size * stat.blocks / 1024**3).round(2),
19
+ free_gb: (stat.block_size * stat.blocks_available / 1024**3).round(2),
20
+ used_pct: ((1 - stat.blocks_available.to_f / stat.blocks) * 100).round(1)
21
+ }
22
+ else
23
+ # Fallback: use df
24
+ output = `df -k .`.split("\n").last.split
25
+ total = output[1].to_i / 1024 / 1024.0
26
+ free = output[3].to_i / 1024 / 1024.0
27
+ {
28
+ total_gb: total.round(2),
29
+ free_gb: free.round(2),
30
+ used_pct: output[4]
31
+ }
32
+ end
33
+ end
34
+
35
+ register_tool('get_environment_variables', 'List environment variables (masked secrets)') do |prefix: ''|
36
+ secret_patterns = %w[KEY SECRET PASSWORD TOKEN CREDENTIAL]
37
+ result = {}
38
+ ENV.each do |k, v|
39
+ next if !prefix.empty? && !k.upcase.start_with?(prefix.upcase)
40
+
41
+ if secret_patterns.any? { |s| k.upcase.include?(s) }
42
+ result[k] = '***masked***'
43
+ else
44
+ result[k] = v
45
+ end
46
+ end
47
+ { variables: result, count: result.size }
48
+ end
49
+
50
+ register_tool('get_process_info', 'Get process info: PID, CPU time, user') do
51
+ rss = `ps -o rss= -p #{Process.pid}`.to_i
52
+ {
53
+ pid: Process.pid,
54
+ ppid: Process.ppid,
55
+ rss_mb: (rss / 1024.0).round(2),
56
+ uid: Process.uid,
57
+ gid: Process.gid,
58
+ user: Etc.getpwuid(Process.uid).name
59
+ }
60
+ end
61
+ end
@@ -0,0 +1,67 @@
1
+ module DebugAgent
2
+ register_tool('get_thread_list',
3
+ 'List all threads with status and backtrace summary') do
4
+ threads = Thread.list
5
+ {
6
+ total_threads: threads.size,
7
+ threads: threads.map do |t|
8
+ backtrace = begin
9
+ t.backtrace || []
10
+ rescue => e
11
+ ["<unable to get backtrace: #{e.message}>"]
12
+ end
13
+
14
+ {
15
+ object_id: t.object_id,
16
+ to_s: t.to_s,
17
+ status: t.status.nil? ? 'terminated' : t.status.to_s,
18
+ alive: t.alive?,
19
+ priority: t.priority,
20
+ name: (t.name if t.respond_to?(:name)),
21
+ backtrace_summary: backtrace.first(5),
22
+ backtrace_length: backtrace.size
23
+ }
24
+ end
25
+ }
26
+ rescue => e
27
+ { error: e.message }
28
+ end
29
+
30
+ register_tool('get_thread_count',
31
+ 'Get current thread count') do
32
+ threads = Thread.list
33
+ alive = threads.count(&:alive?)
34
+ sleeping = threads.count { |t| t.status == 'sleep' }
35
+ runnable = threads.count { |t| t.status == 'run' }
36
+
37
+ {
38
+ total: threads.size,
39
+ alive: alive,
40
+ sleeping: sleeping,
41
+ runnable: runnable,
42
+ main_thread: Thread.main.object_id
43
+ }
44
+ rescue => e
45
+ { error: e.message }
46
+ end
47
+
48
+ register_tool('get_main_thread_info',
49
+ 'Get main thread info: priority, status, name') do
50
+ main = Thread.main
51
+ backtrace = main.backtrace || []
52
+
53
+ {
54
+ object_id: main.object_id,
55
+ status: main.status.to_s,
56
+ alive: main.alive?,
57
+ priority: main.priority,
58
+ name: main.respond_to?(:name) ? main.name : nil,
59
+ backtrace_length: backtrace.size,
60
+ backtrace_top: backtrace.first(10),
61
+ thread_group: main.group.to_s,
62
+ ruby_thread_id: (main.respond_to?(:native_thread_id) ? main.native_thread_id : nil)
63
+ }
64
+ rescue => e
65
+ { error: e.message }
66
+ end
67
+ end
@@ -0,0 +1,221 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'timeout'
5
+
6
+ module DebugAgent
7
+ # Stream callback interface (engine implements this)
8
+ class StreamHandler
9
+ def on_content(chunk); end
10
+ def on_complete(tool_calls, finish_reason, usage); end
11
+ def on_error(error); end
12
+ end
13
+
14
+ # Chat callback interface (engine -> UI)
15
+ class ChatCallback
16
+ def on_content(chunk); end
17
+ def on_tool_start(tool_name, args); end
18
+ def on_tool_result(tool_name, result); end
19
+ def on_complete; end
20
+ def on_error(message); end
21
+ def on_context_compressed(original, compressed, removed_rounds); end
22
+ end
23
+
24
+ class LLMClient
25
+ def initialize(config)
26
+ @cfg = config
27
+ end
28
+
29
+ # ==================== Non-Streaming ====================
30
+
31
+ def chat(messages, tools = nil)
32
+ body = {
33
+ 'model' => @cfg.model,
34
+ 'messages' => messages,
35
+ 'temperature' => 0,
36
+ 'max_tokens' => 1024
37
+ }
38
+ body['tools'] = tools if tools
39
+
40
+ post_with_retry('/chat/completions', body)
41
+ end
42
+
43
+ # ==================== Streaming ====================
44
+
45
+ def chat_stream_raw(messages, tools, tool_choice, handler)
46
+ body = {
47
+ 'model' => @cfg.model,
48
+ 'messages' => messages,
49
+ 'temperature' => @cfg.temperature,
50
+ 'max_tokens' => @cfg.max_tokens,
51
+ 'stream' => true,
52
+ 'stream_options' => { 'include_usage' => true }
53
+ }
54
+ body['tools'] = tools if tools && tools.any?
55
+ body['tool_choice'] = tool_choice if tool_choice
56
+
57
+ max_retries = @cfg.max_retries
58
+ last_error = nil
59
+
60
+ (0..max_retries).each do |attempt|
61
+ begin
62
+ stream_request('/chat/completions', body, handler)
63
+ return
64
+ rescue RetriableError => e
65
+ last_error = e
66
+ if attempt < max_retries
67
+ delay = calculate_delay(attempt)
68
+ sleep(delay / 1000.0)
69
+ next
70
+ end
71
+ handler.on_error(e)
72
+ return
73
+ rescue StandardError => e
74
+ handler.on_error(e)
75
+ return
76
+ end
77
+ end
78
+
79
+ handler.on_error(StandardError.new("Exhausted retries: #{last_error&.message}"))
80
+ end
81
+
82
+ # ==================== Stream Processing ====================
83
+
84
+ def stream_request(path, body, handler)
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
+
90
+ request = Net::HTTP::Post.new(uri.path)
91
+ request['Authorization'] = "Bearer #{@cfg.api_key}"
92
+ request['Content-Type'] = 'application/json'
93
+ request.body = JSON.generate(body)
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
100
+
101
+ tool_call_map = {}
102
+ finish_reason = nil
103
+ usage = nil
104
+
105
+ response.read_body do |chunk|
106
+ chunk.split("\n").each do |line|
107
+ next unless line.start_with?('data: ')
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'])
130
+ end
131
+
132
+ if delta['tool_calls']
133
+ delta['tool_calls'].each do |tc|
134
+ idx = tc['index'] || 0
135
+ tool_call_map[idx] ||= { 'id' => '', 'type' => 'function', 'function' => { 'name' => '', 'arguments' => '' } }
136
+ entry = tool_call_map[idx]
137
+ entry['id'] = tc['id'] if tc['id']
138
+ entry['type'] = tc['type'] if tc['type']
139
+ fn = tc['function'] || {}
140
+ entry['function']['name'] += fn['name'] if fn['name']
141
+ entry['function']['arguments'] += fn['arguments'] if fn['arguments']
142
+ end
143
+ end
144
+
145
+ finish_reason = choice['finish_reason'] if choice['finish_reason']
146
+ end
147
+ end
148
+
149
+ tool_calls = tool_call_map.keys.sort.map { |k| tool_call_map[k] }.select { |tc| tc['function']['name'] && !tc['function']['name'].empty? }
150
+
151
+ handler.on_complete(tool_calls, finish_reason, usage)
152
+ end
153
+
154
+ # ==================== Non-Streaming POST with retry ====================
155
+
156
+ def post_with_retry(path, body)
157
+ max_retries = @cfg.max_retries
158
+ last_error = nil
159
+
160
+ (0..max_retries).each do |attempt|
161
+ begin
162
+ return post(path, body)
163
+ rescue RetriableError => e
164
+ last_error = e
165
+ if attempt < max_retries
166
+ delay = calculate_delay(attempt)
167
+ sleep(delay / 1000.0)
168
+ next
169
+ end
170
+ raise
171
+ rescue StandardError => e
172
+ raise
173
+ end
174
+ end
175
+
176
+ raise last_error
177
+ end
178
+
179
+ def post(path, body)
180
+ uri = URI(@cfg.base_url + path)
181
+ http = Net::HTTP.new(uri.host, uri.port)
182
+ http.use_ssl = uri.scheme == 'https'
183
+ http.read_timeout = @cfg.timeout_seconds
184
+
185
+ request = Net::HTTP::Post.new(uri.path)
186
+ request['Authorization'] = "Bearer #{@cfg.api_key}"
187
+ request['Content-Type'] = 'application/json'
188
+ request.body = JSON.generate(body)
189
+
190
+ response = http.request(request)
191
+
192
+ if response.code.to_i >= 400
193
+ raise RetriableError.new(response.code.to_i, "HTTP #{response.code}: #{response.body}")
194
+ end
195
+
196
+ JSON.parse(response.body)
197
+ end
198
+
199
+ # ==================== Helpers ====================
200
+
201
+ def calculate_delay(attempt)
202
+ base = @cfg.retry_base_delay_ms * (2 ** attempt)
203
+ jitter = rand(base / 2 + 1)
204
+ delay = base + jitter
205
+ [delay, @cfg.retry_max_delay_ms].min
206
+ end
207
+ end
208
+
209
+ class RetriableError < StandardError
210
+ attr_reader :status_code
211
+
212
+ def initialize(status_code, message)
213
+ super(message)
214
+ @status_code = status_code
215
+ end
216
+
217
+ def retriable?
218
+ [429, 500, 502, 503, 504].include?(@status_code)
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,127 @@
1
+ require 'json'
2
+ require_relative 'chat_page'
3
+
4
+ module DebugAgent
5
+ # SSE callback that bridges engine to SSE response lines
6
+ class SseCallback < ChatCallback
7
+ def initialize
8
+ @events = []
9
+ end
10
+
11
+ def events
12
+ @events
13
+ end
14
+
15
+ def on_content(chunk)
16
+ @events << ['content', JSON.generate(chunk)]
17
+ end
18
+
19
+ def on_tool_start(tool_name, args)
20
+ @events << ['tool_start', tool_name]
21
+ end
22
+
23
+ def on_tool_result(tool_name, result)
24
+ @events << ['tool_result', "#{tool_name}: #{result}"]
25
+ end
26
+
27
+ def on_complete
28
+ @events << ['done', '']
29
+ end
30
+
31
+ def on_error(message)
32
+ @events << ['error', message]
33
+ end
34
+
35
+ def on_context_compressed(original, compressed, removed_rounds)
36
+ info = JSON.generate({ originalTokens: original, compressedTokens: compressed, removedRounds: removed_rounds })
37
+ @events << ['context_compressed', info]
38
+ end
39
+ end
40
+
41
+ # Rack-compatible middleware class (use with Sinatra: `use DebugAgent::RackMiddleware`)
42
+ class RackMiddleware
43
+ def initialize(app, config = nil)
44
+ @app = app
45
+ @config = config || Config.from_env
46
+ @engine = DebugEngine.new(@config)
47
+ DebugAgent.app = app
48
+ end
49
+
50
+ def call(env)
51
+ MiddlewareCore.call(env, @app, @engine, @config)
52
+ end
53
+ end
54
+
55
+ module Middleware
56
+ # Create a lambda-based middleware (for non-Sinatra Rack apps)
57
+ def self.new(app = nil, config = nil)
58
+ config_obj = config || Config.from_env
59
+ engine = DebugEngine.new(config_obj)
60
+ DebugAgent.app = app
61
+
62
+ lambda do |env|
63
+ MiddlewareCore.call(env, app, engine, config_obj)
64
+ end
65
+ end
66
+ end
67
+
68
+ # Shared routing logic used by both RackMiddleware class and Middleware lambda
69
+ module MiddlewareCore
70
+ def self.call(env, app, engine, config)
71
+ path = env['PATH_INFO']
72
+ method = env['REQUEST_METHOD']
73
+ base = config.base_path
74
+
75
+ # Chat UI
76
+ if path == base || path == "#{base}/"
77
+ if method == 'GET'
78
+ html = ChatPage.render(base)
79
+ return [200, { 'Content-Type' => 'text/html; charset=utf-8' }, [html]]
80
+ end
81
+ end
82
+
83
+ # SSE streaming chat
84
+ if path == "#{base}/api/chat" && method == 'POST'
85
+ body = JSON.parse(env['rack.input'].read)
86
+ message = body['message'] || ''
87
+ session_id = body['sessionId'] || "session-#{Time.now.to_i}"
88
+
89
+ cb = SseCallback.new
90
+ engine.chat(message, session_id, cb)
91
+
92
+ stream = cb.events.map { |event_type, data| "event: #{event_type}\ndata: #{data}\n\n" }.join
93
+
94
+ return [200, {
95
+ 'Content-Type' => 'text/event-stream',
96
+ 'Cache-Control' => 'no-cache',
97
+ 'Connection' => 'keep-alive'
98
+ }, [stream]]
99
+ end
100
+
101
+ # Clear conversation
102
+ if path == "#{base}/api/clear" && method == 'POST'
103
+ body = JSON.parse(env['rack.input'].read)
104
+ session_id = body['sessionId'] || ''
105
+ engine.clear_session(session_id) if session_id && !session_id.empty?
106
+ return [200, { 'Content-Type' => 'application/json' }, [JSON.generate({ status: 'cleared' })]]
107
+ end
108
+
109
+ # Health check
110
+ if path == "#{base}/api/health" && method == 'GET'
111
+ return [200, { 'Content-Type' => 'application/json' }, [JSON.generate({ status: 'ok', agent: 'ruby-debug-agent' })]]
112
+ end
113
+
114
+ # List tools
115
+ if path == "#{base}/api/tools" && method == 'GET'
116
+ return [200, { 'Content-Type' => 'application/json' }, [JSON.generate({ tools: engine.tools.all_schemas })]]
117
+ end
118
+
119
+ # Pass through to the wrapped app
120
+ if app
121
+ app.call(env)
122
+ else
123
+ [404, { 'Content-Type' => 'text/plain' }, ['Not Found']]
124
+ end
125
+ end
126
+ end
127
+ end