debug-agent-py 0.2.1__py3-none-any.whl
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.
- debug_agent/__init__.py +23 -0
- debug_agent/chat_session.py +45 -0
- debug_agent/config.py +48 -0
- debug_agent/context_compressor.py +157 -0
- debug_agent/engine.py +180 -0
- debug_agent/inspectors/__init__.py +13 -0
- debug_agent/inspectors/async_tasks.py +108 -0
- debug_agent/inspectors/database.py +129 -0
- debug_agent/inspectors/framework.py +133 -0
- debug_agent/inspectors/http_tracker.py +93 -0
- debug_agent/inspectors/memory.py +117 -0
- debug_agent/inspectors/modules.py +129 -0
- debug_agent/inspectors/runtime.py +132 -0
- debug_agent/inspectors/system.py +79 -0
- debug_agent/inspectors/threads.py +97 -0
- debug_agent/llm_client.py +195 -0
- debug_agent/middleware.py +199 -0
- debug_agent/system_prompt_builder.py +101 -0
- debug_agent/tool_registry.py +121 -0
- debug_agent/web/__init__.py +0 -0
- debug_agent/web/chat_page.py +582 -0
- debug_agent_py-0.2.1.dist-info/METADATA +160 -0
- debug_agent_py-0.2.1.dist-info/RECORD +25 -0
- debug_agent_py-0.2.1.dist-info/WHEEL +5 -0
- debug_agent_py-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Runtime inspector: GC, memory, threads, process info."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gc
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import tracemalloc
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from debug_agent.tool_registry import debug_tool, ToolParam
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@debug_tool("get_gc_stats", "Get garbage collector statistics: collection counts per generation")
|
|
17
|
+
def get_gc_stats() -> dict:
|
|
18
|
+
counts = gc.get_count() # (gen0, gen1, gen2) counts
|
|
19
|
+
return {
|
|
20
|
+
"generation_counts": {"gen0": counts[0], "gen1": counts[1], "gen2": counts[2]},
|
|
21
|
+
"garbage_count": len(gc.garbage),
|
|
22
|
+
"total_objects": len(gc.get_objects()),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@debug_tool("get_memory_summary", "Get process memory usage: RSS, VMS, and Python heap info")
|
|
27
|
+
def get_memory_summary() -> dict:
|
|
28
|
+
try:
|
|
29
|
+
import resource
|
|
30
|
+
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
31
|
+
# macOS reports in bytes, Linux in KB
|
|
32
|
+
if sys.platform == "darwin":
|
|
33
|
+
rss_mb = rss / 1024 / 1024
|
|
34
|
+
else:
|
|
35
|
+
rss_mb = rss / 1024
|
|
36
|
+
except ImportError:
|
|
37
|
+
rss_mb = 0
|
|
38
|
+
|
|
39
|
+
import ctypes
|
|
40
|
+
info = {"rss_mb": round(rss_mb, 2)}
|
|
41
|
+
|
|
42
|
+
# Python object counts
|
|
43
|
+
counts = {}
|
|
44
|
+
for obj in gc.get_objects():
|
|
45
|
+
t = type(obj).__name__
|
|
46
|
+
counts[t] = counts.get(t, 0) + 1
|
|
47
|
+
top_types = sorted(counts.items(), key=lambda x: -x[1])[:15]
|
|
48
|
+
info["top_object_types"] = {t: c for t, c in top_types}
|
|
49
|
+
info["total_objects"] = sum(counts.values())
|
|
50
|
+
|
|
51
|
+
return info
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@debug_tool("trigger_gc", "Trigger garbage collection and show before/after comparison")
|
|
55
|
+
def trigger_gc() -> dict:
|
|
56
|
+
before = len(gc.get_objects())
|
|
57
|
+
collected = gc.collect()
|
|
58
|
+
after = len(gc.get_objects())
|
|
59
|
+
return {
|
|
60
|
+
"objects_before": before,
|
|
61
|
+
"objects_collected": collected,
|
|
62
|
+
"objects_after": after,
|
|
63
|
+
"freed": before - after,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@debug_tool("get_thread_summary", "Get thread state overview: count, names, daemon status")
|
|
68
|
+
def get_thread_summary() -> dict:
|
|
69
|
+
threads = threading.enumerate()
|
|
70
|
+
return {
|
|
71
|
+
"total_threads": len(threads),
|
|
72
|
+
"threads": [
|
|
73
|
+
{"name": t.name, "ident": t.ident, "daemon": t.daemon, "alive": t.is_alive()}
|
|
74
|
+
for t in threads
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@debug_tool("get_thread_dump", "Get stack traces for all threads")
|
|
80
|
+
def get_thread_dump() -> dict:
|
|
81
|
+
frames = sys._current_frames()
|
|
82
|
+
result = {}
|
|
83
|
+
for tid, frame in frames.items():
|
|
84
|
+
stack = []
|
|
85
|
+
f = frame
|
|
86
|
+
while f:
|
|
87
|
+
stack.append({"file": f.f_code.co_filename, "line": f.f_lineno, "function": f.f_code.co_name})
|
|
88
|
+
f = f.f_back
|
|
89
|
+
# Find thread name
|
|
90
|
+
tname = "unknown"
|
|
91
|
+
for t in threading.enumerate():
|
|
92
|
+
if t.ident == tid:
|
|
93
|
+
tname = t.name
|
|
94
|
+
break
|
|
95
|
+
result[tname] = stack[:20]
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@debug_tool("get_runtime_info", "Get Python runtime info: version, platform, uptime")
|
|
100
|
+
def get_runtime_info() -> dict:
|
|
101
|
+
return {
|
|
102
|
+
"python_version": sys.version,
|
|
103
|
+
"platform": sys.platform,
|
|
104
|
+
"executable": sys.executable,
|
|
105
|
+
"pid": os.getpid(),
|
|
106
|
+
"argv": sys.argv,
|
|
107
|
+
"path_count": len(sys.path),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@debug_tool("get_memory_allocations", "Get top memory allocations using tracemalloc (if enabled)")
|
|
112
|
+
def get_memory_allocations() -> dict:
|
|
113
|
+
if not tracemalloc.is_tracing():
|
|
114
|
+
return {"error": "tracemalloc is not enabled. Call tracemalloc.start() to enable."}
|
|
115
|
+
snapshot = tracemalloc.take_snapshot()
|
|
116
|
+
top = snapshot.statistics("lineno")
|
|
117
|
+
return {
|
|
118
|
+
"top_allocations": [
|
|
119
|
+
{"file": str(s.traceback), "size_kb": round(s.size / 1024, 2), "count": s.count}
|
|
120
|
+
for s in top[:20]
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@debug_tool("get_tracemalloc_status", "Check if tracemalloc is enabled and its configuration")
|
|
126
|
+
def get_tracemalloc_status() -> dict:
|
|
127
|
+
return {
|
|
128
|
+
"enabled": tracemalloc.is_tracing(),
|
|
129
|
+
"traced_memory_mb": round(sum((c.size for c in tracemalloc.get_traced_memory())) / 1024 / 1024, 2)
|
|
130
|
+
if tracemalloc.is_tracing()
|
|
131
|
+
else 0,
|
|
132
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""System inspector: process info, CPU, disk, network."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from debug_agent.tool_registry import debug_tool, ToolParam
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@debug_tool("get_process_info", "Get process info: PID, CPU usage, memory limits, container detection")
|
|
15
|
+
def get_process_info() -> dict:
|
|
16
|
+
try:
|
|
17
|
+
import resource
|
|
18
|
+
cpu_time = resource.getrusage(resource.RUSAGE_SELF)
|
|
19
|
+
cpu_user = cpu_time.ru_utime
|
|
20
|
+
cpu_sys = cpu_time.ru_stime
|
|
21
|
+
except ImportError:
|
|
22
|
+
cpu_user = cpu_sys = 0
|
|
23
|
+
|
|
24
|
+
# Container detection
|
|
25
|
+
in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
|
|
26
|
+
try:
|
|
27
|
+
with open("/proc/1/cgroup", "t") as f:
|
|
28
|
+
if "docker" in f.read() or "kubepods" in f.read():
|
|
29
|
+
in_container = True
|
|
30
|
+
except (FileNotFoundError, IOError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
"pid": os.getpid(),
|
|
35
|
+
"ppid": os.getppid(),
|
|
36
|
+
"cpu_time": {"user_seconds": round(cpu_user, 2), "system_seconds": round(cpu_sys, 2)},
|
|
37
|
+
"platform": platform.platform(),
|
|
38
|
+
"hostname": socket.gethostname(),
|
|
39
|
+
"in_container": in_container,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@debug_tool("get_system_info", "Get system information: OS, CPU cores, load average")
|
|
44
|
+
def get_system_info() -> dict:
|
|
45
|
+
return {
|
|
46
|
+
"os": platform.platform(),
|
|
47
|
+
"python_version": sys.version.split()[0],
|
|
48
|
+
"cpu_count": os.cpu_count(),
|
|
49
|
+
"load_average": _get_loadavg(),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_loadavg() -> dict | None:
|
|
54
|
+
try:
|
|
55
|
+
avg = os.getloadavg()
|
|
56
|
+
return {"1min": avg[0], "5min": avg[1], "15min": avg[2]}
|
|
57
|
+
except (AttributeError, OSError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@debug_tool("get_disk_usage", "Get disk usage for current working directory")
|
|
62
|
+
def get_disk_usage() -> dict:
|
|
63
|
+
stat = os.statvfs(".")
|
|
64
|
+
total = stat.f_blocks * stat.f_frsize
|
|
65
|
+
free = stat.f_bavail * stat.f_frsize
|
|
66
|
+
return {
|
|
67
|
+
"total_gb": round(total / 1024**3, 2),
|
|
68
|
+
"free_gb": round(free / 1024**3, 2),
|
|
69
|
+
"used_pct": round((1 - free / total) * 100, 1),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@debug_tool("get_python_path", "Get Python module search paths")
|
|
74
|
+
def get_python_path() -> dict:
|
|
75
|
+
return {
|
|
76
|
+
"paths": sys.path,
|
|
77
|
+
"count": len(sys.path),
|
|
78
|
+
"site_packages": [p for p in sys.path if "site-packages" in p],
|
|
79
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Thread inspector: live thread info, counts, and per-thread tracebacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from debug_agent.tool_registry import debug_tool, ToolParam
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@debug_tool(
|
|
13
|
+
"get_thread_info",
|
|
14
|
+
"List all threads with name, daemon status, and alive status",
|
|
15
|
+
)
|
|
16
|
+
def get_thread_info() -> dict:
|
|
17
|
+
threads = threading.enumerate()
|
|
18
|
+
return {
|
|
19
|
+
"total_threads": len(threads),
|
|
20
|
+
"active_count": threading.active_count(),
|
|
21
|
+
"current_thread": threading.current_thread().name,
|
|
22
|
+
"threads": [
|
|
23
|
+
{
|
|
24
|
+
"name": t.name,
|
|
25
|
+
"ident": t.ident,
|
|
26
|
+
"daemon": t.daemon,
|
|
27
|
+
"alive": t.is_alive(),
|
|
28
|
+
"native_id": getattr(t, "native_id", None),
|
|
29
|
+
}
|
|
30
|
+
for t in threads
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@debug_tool(
|
|
36
|
+
"get_thread_count",
|
|
37
|
+
"Get the number of active threads in the process",
|
|
38
|
+
)
|
|
39
|
+
def get_thread_count() -> dict:
|
|
40
|
+
return {
|
|
41
|
+
"active_count": threading.active_count(),
|
|
42
|
+
"enumerated_count": len(threading.enumerate()),
|
|
43
|
+
"main_thread_alive": threading.main_thread().is_alive(),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@debug_tool(
|
|
48
|
+
"get_thread_traceback",
|
|
49
|
+
"Get the Python traceback (stack frames) for a specific thread by name or ID",
|
|
50
|
+
)
|
|
51
|
+
def get_thread_traceback(
|
|
52
|
+
thread_name: str = ToolParam("Thread name to look up (use 'MainThread' for main thread)", required=False),
|
|
53
|
+
thread_id: int = ToolParam("Thread identifier (OS-level TID) to look up", required=False),
|
|
54
|
+
) -> dict:
|
|
55
|
+
frames = sys._current_frames()
|
|
56
|
+
thread_map = {t.ident: t for t in threading.enumerate()}
|
|
57
|
+
|
|
58
|
+
target_tid = None
|
|
59
|
+
matched_name = None
|
|
60
|
+
|
|
61
|
+
if thread_id is not None:
|
|
62
|
+
target_tid = thread_id
|
|
63
|
+
t = thread_map.get(thread_id)
|
|
64
|
+
matched_name = t.name if t else f"tid-{thread_id}"
|
|
65
|
+
elif thread_name:
|
|
66
|
+
for t in threading.enumerate():
|
|
67
|
+
if t.name == thread_name:
|
|
68
|
+
target_tid = t.ident
|
|
69
|
+
matched_name = t.name
|
|
70
|
+
break
|
|
71
|
+
if target_tid is None:
|
|
72
|
+
return {"error": f"No thread found with name '{thread_name}'"}
|
|
73
|
+
else:
|
|
74
|
+
# Default to current thread
|
|
75
|
+
target_tid = threading.current_thread().ident
|
|
76
|
+
matched_name = threading.current_thread().name
|
|
77
|
+
|
|
78
|
+
frame = frames.get(target_tid)
|
|
79
|
+
if not frame:
|
|
80
|
+
return {"error": f"No frame found for thread '{matched_name}' (tid={target_tid})"}
|
|
81
|
+
|
|
82
|
+
stack = []
|
|
83
|
+
f = frame
|
|
84
|
+
while f:
|
|
85
|
+
stack.append({
|
|
86
|
+
"file": f.f_code.co_filename,
|
|
87
|
+
"line": f.f_lineno,
|
|
88
|
+
"function": f.f_code.co_name,
|
|
89
|
+
})
|
|
90
|
+
f = f.f_back
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"thread_name": matched_name,
|
|
94
|
+
"thread_id": target_tid,
|
|
95
|
+
"stack_depth": len(stack),
|
|
96
|
+
"frames": stack[:30],
|
|
97
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""OpenAI-compatible LLM client with real streaming, retry, and token usage tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from debug_agent.config import LLMConfig
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("debug_agent")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StreamHandler:
|
|
19
|
+
"""Low-level stream callback interface (engine implements this)."""
|
|
20
|
+
|
|
21
|
+
def on_content(self, chunk: str): ...
|
|
22
|
+
def on_complete(self, tool_calls: list[dict], finish_reason: str | None, usage: dict | None): ...
|
|
23
|
+
def on_error(self, error: Exception): ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LLMClient:
|
|
27
|
+
"""OpenAI-compatible chat client with real streaming and retry."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: LLMConfig):
|
|
30
|
+
self.cfg = config
|
|
31
|
+
self.client = httpx.Client(timeout=config.timeout_seconds)
|
|
32
|
+
|
|
33
|
+
# ==================== Non-Streaming ====================
|
|
34
|
+
|
|
35
|
+
def chat(self, messages: list[dict], tools: list[dict] | None = None) -> dict:
|
|
36
|
+
"""Non-streaming chat completion with retry."""
|
|
37
|
+
body: dict = {
|
|
38
|
+
"model": self.cfg.model,
|
|
39
|
+
"messages": messages,
|
|
40
|
+
"temperature": 0,
|
|
41
|
+
"max_tokens": 1024,
|
|
42
|
+
}
|
|
43
|
+
if tools:
|
|
44
|
+
body["tools"] = tools
|
|
45
|
+
|
|
46
|
+
return self._post_with_retry("/chat/completions", body)
|
|
47
|
+
|
|
48
|
+
# ==================== Streaming ====================
|
|
49
|
+
|
|
50
|
+
def chat_stream_raw(self, messages: list[dict], tools: list[dict] | None, tool_choice: Any, handler: StreamHandler):
|
|
51
|
+
"""Streaming chat completion with retry. Calls handler callbacks."""
|
|
52
|
+
body: dict = {
|
|
53
|
+
"model": self.cfg.model,
|
|
54
|
+
"messages": messages,
|
|
55
|
+
"temperature": self.cfg.temperature,
|
|
56
|
+
"max_tokens": self.cfg.max_tokens,
|
|
57
|
+
"stream": True,
|
|
58
|
+
"stream_options": {"include_usage": True},
|
|
59
|
+
}
|
|
60
|
+
if tools:
|
|
61
|
+
body["tools"] = tools
|
|
62
|
+
if tool_choice:
|
|
63
|
+
body["tool_choice"] = tool_choice
|
|
64
|
+
|
|
65
|
+
max_retries = self.cfg.max_retries
|
|
66
|
+
last_error = None
|
|
67
|
+
|
|
68
|
+
for attempt in range(max_retries + 1):
|
|
69
|
+
try:
|
|
70
|
+
self._stream_request("/chat/completions", body, handler)
|
|
71
|
+
return
|
|
72
|
+
except Exception as e:
|
|
73
|
+
last_error = e
|
|
74
|
+
if self._is_retriable(e) and attempt < max_retries:
|
|
75
|
+
delay = self._calculate_delay(attempt)
|
|
76
|
+
time.sleep(delay / 1000.0)
|
|
77
|
+
continue
|
|
78
|
+
handler.on_error(e)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
handler.on_error(Exception(f"Exhausted retries after {max_retries} attempts: {last_error}"))
|
|
82
|
+
|
|
83
|
+
# ==================== Stream Processing ====================
|
|
84
|
+
|
|
85
|
+
def _stream_request(self, path: str, body: dict, handler: StreamHandler):
|
|
86
|
+
url = f"{self.cfg.base_url}{path}"
|
|
87
|
+
|
|
88
|
+
with self.client.stream("POST", url, headers=self._headers(), json=body) as resp:
|
|
89
|
+
if resp.status_code >= 400:
|
|
90
|
+
error_body = resp.read().decode()
|
|
91
|
+
raise RetriableError(resp.status_code, f"HTTP {resp.status_code}: {error_body}")
|
|
92
|
+
|
|
93
|
+
tool_call_map: dict[int, dict] = {}
|
|
94
|
+
finish_reason = None
|
|
95
|
+
usage = None
|
|
96
|
+
|
|
97
|
+
for line in resp.iter_lines():
|
|
98
|
+
if not line or not line.startswith("data: "):
|
|
99
|
+
continue
|
|
100
|
+
data_str = line[6:]
|
|
101
|
+
if data_str.strip() == "[DONE]":
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
chunk = json.loads(data_str)
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if chunk.get("usage") and chunk["usage"].get("prompt_tokens"):
|
|
110
|
+
usage = chunk["usage"]
|
|
111
|
+
|
|
112
|
+
choices = chunk.get("choices", [])
|
|
113
|
+
if not choices:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
choice = choices[0]
|
|
117
|
+
delta = choice.get("delta", {})
|
|
118
|
+
|
|
119
|
+
if delta.get("content"):
|
|
120
|
+
handler.on_content(delta["content"])
|
|
121
|
+
|
|
122
|
+
if delta.get("tool_calls"):
|
|
123
|
+
for tc in delta["tool_calls"]:
|
|
124
|
+
idx = tc.get("index", 0)
|
|
125
|
+
if idx not in tool_call_map:
|
|
126
|
+
tool_call_map[idx] = {"id": "", "type": "function", "function": {"name": "", "arguments": ""}}
|
|
127
|
+
entry = tool_call_map[idx]
|
|
128
|
+
if tc.get("id"):
|
|
129
|
+
entry["id"] = tc["id"]
|
|
130
|
+
if tc.get("type"):
|
|
131
|
+
entry["type"] = tc["type"]
|
|
132
|
+
fn = tc.get("function", {})
|
|
133
|
+
if fn.get("name"):
|
|
134
|
+
entry["function"]["name"] += fn["name"]
|
|
135
|
+
if fn.get("arguments") is not None:
|
|
136
|
+
entry["function"]["arguments"] += fn["arguments"]
|
|
137
|
+
|
|
138
|
+
if choice.get("finish_reason"):
|
|
139
|
+
finish_reason = choice["finish_reason"]
|
|
140
|
+
|
|
141
|
+
tool_calls = [tool_call_map[k] for k in sorted(tool_call_map.keys()) if tool_call_map[k]["function"]["name"]]
|
|
142
|
+
handler.on_complete(tool_calls, finish_reason, usage)
|
|
143
|
+
|
|
144
|
+
# ==================== Non-Streaming POST with retry ====================
|
|
145
|
+
|
|
146
|
+
def _post_with_retry(self, path: str, body: dict) -> dict:
|
|
147
|
+
max_retries = self.cfg.max_retries
|
|
148
|
+
last_error = None
|
|
149
|
+
|
|
150
|
+
for attempt in range(max_retries + 1):
|
|
151
|
+
try:
|
|
152
|
+
url = f"{self.cfg.base_url}{path}"
|
|
153
|
+
resp = self.client.post(url, headers=self._headers(), json=body)
|
|
154
|
+
if resp.status_code >= 400:
|
|
155
|
+
raise RetriableError(resp.status_code, f"HTTP {resp.status_code}: {resp.text}")
|
|
156
|
+
return resp.json()
|
|
157
|
+
except Exception as e:
|
|
158
|
+
last_error = e
|
|
159
|
+
if self._is_retriable(e) and attempt < max_retries:
|
|
160
|
+
delay = self._calculate_delay(attempt)
|
|
161
|
+
time.sleep(delay / 1000.0)
|
|
162
|
+
continue
|
|
163
|
+
raise
|
|
164
|
+
|
|
165
|
+
raise last_error
|
|
166
|
+
|
|
167
|
+
# ==================== Helpers ====================
|
|
168
|
+
|
|
169
|
+
def _headers(self) -> dict:
|
|
170
|
+
return {"Authorization": f"Bearer {self.cfg.api_key}", "Content-Type": "application/json"}
|
|
171
|
+
|
|
172
|
+
def _is_retriable(self, error: Exception) -> bool:
|
|
173
|
+
if isinstance(error, RetriableError):
|
|
174
|
+
return error.is_retriable()
|
|
175
|
+
return True # Network errors
|
|
176
|
+
|
|
177
|
+
def _calculate_delay(self, attempt: int) -> int:
|
|
178
|
+
base = self.cfg.retry_base_delay_ms * (2 ** attempt)
|
|
179
|
+
jitter = random.randint(0, base // 2)
|
|
180
|
+
delay = base + jitter
|
|
181
|
+
return min(delay, self.cfg.retry_max_delay_ms)
|
|
182
|
+
|
|
183
|
+
def close(self):
|
|
184
|
+
self.client.close()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class RetriableError(Exception):
|
|
188
|
+
"""HTTP error that can be retried."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, status_code: int, message: str):
|
|
191
|
+
super().__init__(message)
|
|
192
|
+
self.status_code = status_code
|
|
193
|
+
|
|
194
|
+
def is_retriable(self) -> bool:
|
|
195
|
+
return self.status_code in (429, 500, 502, 503, 504)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Framework integration: FastAPI, Starlette, Flask middleware.
|
|
2
|
+
|
|
3
|
+
SSE events (Spring-aligned):
|
|
4
|
+
content, tool_start, tool_result, done, error, context_compressed
|
|
5
|
+
|
|
6
|
+
Endpoints:
|
|
7
|
+
GET /agent — Chat UI
|
|
8
|
+
POST /agent/api/chat — SSE streaming chat
|
|
9
|
+
POST /agent/api/clear — Clear conversation
|
|
10
|
+
GET /agent/api/health — Health check
|
|
11
|
+
GET /agent/api/tools — List available tools
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from debug_agent.config import AgentConfig
|
|
20
|
+
from debug_agent.engine import DebugEngine, ChatCallback
|
|
21
|
+
from debug_agent.web.chat_page import render as render_chat_page
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ==================== SSE Callback ====================
|
|
25
|
+
|
|
26
|
+
class SseCallback(ChatCallback):
|
|
27
|
+
"""Bridges engine callbacks to SSE event lines."""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self.events: list[tuple[str, str]] = []
|
|
31
|
+
|
|
32
|
+
def on_content(self, chunk: str):
|
|
33
|
+
self.events.append(("content", json.dumps(chunk)))
|
|
34
|
+
|
|
35
|
+
def on_tool_start(self, tool_name: str, args: str):
|
|
36
|
+
self.events.append(("tool_start", tool_name))
|
|
37
|
+
|
|
38
|
+
def on_tool_result(self, tool_name: str, result: str):
|
|
39
|
+
self.events.append(("tool_result", f"{tool_name}: {result}"))
|
|
40
|
+
|
|
41
|
+
def on_complete(self):
|
|
42
|
+
self.events.append(("done", ""))
|
|
43
|
+
|
|
44
|
+
def on_error(self, message: str):
|
|
45
|
+
self.events.append(("error", message))
|
|
46
|
+
|
|
47
|
+
def on_context_compressed(self, original_tokens: int, compressed_tokens: int, removed_rounds: int):
|
|
48
|
+
info = json.dumps({"originalTokens": original_tokens, "compressedTokens": compressed_tokens, "removedRounds": removed_rounds})
|
|
49
|
+
self.events.append(("context_compressed", info))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ==================== FastAPI ====================
|
|
53
|
+
|
|
54
|
+
def create_fastapi_router(config: AgentConfig | None = None):
|
|
55
|
+
"""Create a FastAPI APIRouter with all debug agent endpoints."""
|
|
56
|
+
from fastapi import APIRouter, Request
|
|
57
|
+
from fastapi.responses import HTMLResponse, StreamingResponse
|
|
58
|
+
from debug_agent import inspectors # noqa: F401
|
|
59
|
+
|
|
60
|
+
cfg = config or AgentConfig.from_env()
|
|
61
|
+
engine = DebugEngine(cfg)
|
|
62
|
+
router = APIRouter(prefix=cfg.base_path, tags=["Debug Agent"])
|
|
63
|
+
|
|
64
|
+
@router.get("", response_class=HTMLResponse)
|
|
65
|
+
async def chat_page():
|
|
66
|
+
return render_chat_page(cfg.base_path)
|
|
67
|
+
|
|
68
|
+
@router.get("/")
|
|
69
|
+
async def chat_page_slash():
|
|
70
|
+
return render_chat_page(cfg.base_path)
|
|
71
|
+
|
|
72
|
+
@router.post("/api/chat")
|
|
73
|
+
async def chat(request: Request):
|
|
74
|
+
body = await request.json()
|
|
75
|
+
message = body.get("message", "")
|
|
76
|
+
session_id = body.get("sessionId", f"session-{id(request)}")
|
|
77
|
+
|
|
78
|
+
def event_stream():
|
|
79
|
+
cb = SseCallback()
|
|
80
|
+
engine.chat(message, session_id, cb)
|
|
81
|
+
for event_type, data in cb.events:
|
|
82
|
+
yield f"event: {event_type}\ndata: {data}\n\n"
|
|
83
|
+
|
|
84
|
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
|
85
|
+
|
|
86
|
+
@router.post("/api/clear")
|
|
87
|
+
async def clear_conversation(request: Request):
|
|
88
|
+
body = await request.json()
|
|
89
|
+
session_id = body.get("sessionId", "")
|
|
90
|
+
if session_id:
|
|
91
|
+
engine.clear_session(session_id)
|
|
92
|
+
return {"status": "cleared"}
|
|
93
|
+
|
|
94
|
+
@router.get("/api/health")
|
|
95
|
+
async def health():
|
|
96
|
+
return {"status": "ok", "agent": "python-debug-agent"}
|
|
97
|
+
|
|
98
|
+
@router.get("/api/tools")
|
|
99
|
+
async def list_tools():
|
|
100
|
+
return {"tools": engine.tools.all_schemas()}
|
|
101
|
+
|
|
102
|
+
return router
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ==================== Starlette ====================
|
|
106
|
+
|
|
107
|
+
def create_starlette_app(config: AgentConfig | None = None):
|
|
108
|
+
from starlette.applications import Starlette
|
|
109
|
+
from starlette.responses import HTMLResponse, StreamingResponse, JSONResponse
|
|
110
|
+
from starlette.routing import Route
|
|
111
|
+
from debug_agent import inspectors # noqa: F401
|
|
112
|
+
|
|
113
|
+
cfg = config or AgentConfig.from_env()
|
|
114
|
+
engine = DebugEngine(cfg)
|
|
115
|
+
|
|
116
|
+
async def chat_page(request):
|
|
117
|
+
return HTMLResponse(render_chat_page(cfg.base_path))
|
|
118
|
+
|
|
119
|
+
async def chat(request):
|
|
120
|
+
body = await request.json()
|
|
121
|
+
message = body.get("message", "")
|
|
122
|
+
session_id = body.get("sessionId", f"session-{id(request)}")
|
|
123
|
+
|
|
124
|
+
def event_stream():
|
|
125
|
+
cb = SseCallback()
|
|
126
|
+
engine.chat(message, session_id, cb)
|
|
127
|
+
for event_type, data in cb.events:
|
|
128
|
+
yield f"event: {event_type}\ndata: {data}\n\n"
|
|
129
|
+
|
|
130
|
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
|
131
|
+
|
|
132
|
+
async def clear(request):
|
|
133
|
+
body = await request.json()
|
|
134
|
+
session_id = body.get("sessionId", "")
|
|
135
|
+
if session_id:
|
|
136
|
+
engine.clear_session(session_id)
|
|
137
|
+
return JSONResponse({"status": "cleared"})
|
|
138
|
+
|
|
139
|
+
async def health(request):
|
|
140
|
+
return JSONResponse({"status": "ok", "agent": "python-debug-agent"})
|
|
141
|
+
|
|
142
|
+
async def tools(request):
|
|
143
|
+
return JSONResponse({"tools": engine.tools.all_schemas()})
|
|
144
|
+
|
|
145
|
+
routes = [
|
|
146
|
+
Route("/", chat_page),
|
|
147
|
+
Route("/api/chat", chat, methods=["POST"]),
|
|
148
|
+
Route("/api/clear", clear, methods=["POST"]),
|
|
149
|
+
Route("/api/health", health),
|
|
150
|
+
Route("/api/tools", tools),
|
|
151
|
+
]
|
|
152
|
+
return Starlette(routes=routes)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ==================== Flask ====================
|
|
156
|
+
|
|
157
|
+
def create_flask_blueprint(config: AgentConfig | None = None):
|
|
158
|
+
from flask import Blueprint, Response, request, jsonify
|
|
159
|
+
from debug_agent import inspectors # noqa: F401
|
|
160
|
+
|
|
161
|
+
cfg = config or AgentConfig.from_env()
|
|
162
|
+
engine = DebugEngine(cfg)
|
|
163
|
+
|
|
164
|
+
bp = Blueprint("debug_agent", __name__, url_prefix=cfg.base_path)
|
|
165
|
+
|
|
166
|
+
@bp.route("", methods=["GET"])
|
|
167
|
+
@bp.route("/", methods=["GET"])
|
|
168
|
+
def chat_page():
|
|
169
|
+
return render_chat_page(cfg.base_path)
|
|
170
|
+
|
|
171
|
+
@bp.route("/api/chat", methods=["POST"])
|
|
172
|
+
def chat():
|
|
173
|
+
message = request.json.get("message", "")
|
|
174
|
+
session_id = request.json.get("sessionId", f"session-{id(request)}")
|
|
175
|
+
|
|
176
|
+
def generate():
|
|
177
|
+
cb = SseCallback()
|
|
178
|
+
engine.chat(message, session_id, cb)
|
|
179
|
+
for event_type, data in cb.events:
|
|
180
|
+
yield f"event: {event_type}\ndata: {data}\n\n"
|
|
181
|
+
|
|
182
|
+
return Response(generate(), mimetype="text/event-stream")
|
|
183
|
+
|
|
184
|
+
@bp.route("/api/clear", methods=["POST"])
|
|
185
|
+
def clear():
|
|
186
|
+
session_id = request.json.get("sessionId", "")
|
|
187
|
+
if session_id:
|
|
188
|
+
engine.clear_session(session_id)
|
|
189
|
+
return jsonify({"status": "cleared"})
|
|
190
|
+
|
|
191
|
+
@bp.route("/api/health", methods=["GET"])
|
|
192
|
+
def health():
|
|
193
|
+
return jsonify({"status": "ok", "agent": "python-debug-agent"})
|
|
194
|
+
|
|
195
|
+
@bp.route("/api/tools", methods=["GET"])
|
|
196
|
+
def tools():
|
|
197
|
+
return jsonify({"tools": engine.tools.all_schemas()})
|
|
198
|
+
|
|
199
|
+
return bp
|