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,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
|