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,129 @@
|
|
|
1
|
+
"""Database inspector: SQLAlchemy engines, connection pools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from debug_agent.tool_registry import debug_tool, ToolParam
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _find_sqlalchemy_engines() -> list:
|
|
12
|
+
"""Find SQLAlchemy Engine instances in loaded modules."""
|
|
13
|
+
engines = []
|
|
14
|
+
seen_ids = set()
|
|
15
|
+
|
|
16
|
+
# Try to find via SQLAlchemy's own registries first
|
|
17
|
+
try:
|
|
18
|
+
from sqlalchemy import inspect as sa_inspect
|
|
19
|
+
from sqlalchemy.engine import Engine
|
|
20
|
+
except ImportError:
|
|
21
|
+
return engines
|
|
22
|
+
|
|
23
|
+
for mod in list(sys.modules.values()):
|
|
24
|
+
if mod is None or mod.__name__.startswith("debug_agent"):
|
|
25
|
+
continue
|
|
26
|
+
for attr_name in dir(mod):
|
|
27
|
+
if attr_name.startswith("_"):
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
obj = getattr(mod, attr_name)
|
|
31
|
+
if isinstance(obj, Engine) and id(obj) not in seen_ids:
|
|
32
|
+
seen_ids.add(id(obj))
|
|
33
|
+
engines.append(obj)
|
|
34
|
+
except Exception:
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
return engines
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@debug_tool(
|
|
41
|
+
"get_sqlalchemy_engines",
|
|
42
|
+
"Find SQLAlchemy engines and their connection pool status",
|
|
43
|
+
)
|
|
44
|
+
def get_sqlalchemy_engines() -> dict:
|
|
45
|
+
try:
|
|
46
|
+
from sqlalchemy import create_engine, inspect as sa_inspect
|
|
47
|
+
from sqlalchemy.pool import QueuePool, NullPool, StaticPool
|
|
48
|
+
except ImportError:
|
|
49
|
+
return {"error": "SQLAlchemy is not installed"}
|
|
50
|
+
|
|
51
|
+
engines = _find_sqlalchemy_engines()
|
|
52
|
+
if not engines:
|
|
53
|
+
return {"message": "No SQLAlchemy engines found in loaded modules"}
|
|
54
|
+
|
|
55
|
+
result = []
|
|
56
|
+
for engine in engines:
|
|
57
|
+
pool = engine.pool
|
|
58
|
+
pool_info: dict = {
|
|
59
|
+
"pool_type": type(pool).__name__,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if isinstance(pool, QueuePool):
|
|
63
|
+
pool_info["size"] = pool.size()
|
|
64
|
+
pool_info["checked_in"] = pool.checkedin()
|
|
65
|
+
pool_info["checked_out"] = pool.checkedout()
|
|
66
|
+
pool_info["overflow"] = pool.overflow()
|
|
67
|
+
pool_info["checked_out_total"] = pool.status()
|
|
68
|
+
|
|
69
|
+
url = str(engine.url)
|
|
70
|
+
# Mask password in URL
|
|
71
|
+
if "" in url:
|
|
72
|
+
parts = url.split("@")
|
|
73
|
+
if len(parts) >= 2:
|
|
74
|
+
cred = parts[0].split(":")
|
|
75
|
+
if len(cred) >= 3:
|
|
76
|
+
cred[-1] = "***"
|
|
77
|
+
parts[0] = ":".join(cred)
|
|
78
|
+
url = "@".join(parts)
|
|
79
|
+
|
|
80
|
+
result.append({
|
|
81
|
+
"url": url,
|
|
82
|
+
"dialect": engine.dialect.name,
|
|
83
|
+
"driver": engine.driver,
|
|
84
|
+
"pool": pool_info,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return {"engine_count": len(result), "engines": result}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@debug_tool(
|
|
91
|
+
"get_db_connections",
|
|
92
|
+
"Inspect database connection pools and active connections if available",
|
|
93
|
+
)
|
|
94
|
+
def get_db_connections() -> dict:
|
|
95
|
+
try:
|
|
96
|
+
from sqlalchemy.pool import QueuePool, SingletonThreadPool, StaticPool
|
|
97
|
+
except ImportError:
|
|
98
|
+
return {"error": "SQLAlchemy is not installed"}
|
|
99
|
+
|
|
100
|
+
engines = _find_sqlalchemy_engines()
|
|
101
|
+
if not engines:
|
|
102
|
+
return {"message": "No SQLAlchemy engines found"}
|
|
103
|
+
|
|
104
|
+
connections = []
|
|
105
|
+
for engine in engines:
|
|
106
|
+
pool = engine.pool
|
|
107
|
+
info: dict = {
|
|
108
|
+
"dialect": engine.dialect.name,
|
|
109
|
+
"pool_type": type(pool).__name__,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if hasattr(pool, "size"):
|
|
113
|
+
info["pool_size"] = pool.size()
|
|
114
|
+
if hasattr(pool, "checkedin"):
|
|
115
|
+
info["checked_in"] = pool.checkedin()
|
|
116
|
+
if hasattr(pool, "checkedout"):
|
|
117
|
+
info["checked_out"] = pool.checkedout()
|
|
118
|
+
if hasattr(pool, "overflow"):
|
|
119
|
+
info["overflow"] = pool.overflow()
|
|
120
|
+
|
|
121
|
+
# Try to get pool status string
|
|
122
|
+
try:
|
|
123
|
+
info["pool_status"] = pool.status()
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
connections.append(info)
|
|
128
|
+
|
|
129
|
+
return {"total_engines": len(connections), "connection_pools": connections}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Framework inspector: routes, middleware, dependency injection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from debug_agent.tool_registry import debug_tool, ToolParam
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _find_app():
|
|
12
|
+
"""Find the main web framework app instance."""
|
|
13
|
+
# Try FastAPI / Starlette
|
|
14
|
+
try:
|
|
15
|
+
import fastapi
|
|
16
|
+
for obj in _walk_module_objects():
|
|
17
|
+
if isinstance(obj, fastapi.FastAPI):
|
|
18
|
+
return ("fastapi", obj)
|
|
19
|
+
except ImportError:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
# Try Flask
|
|
23
|
+
try:
|
|
24
|
+
import flask
|
|
25
|
+
for obj in _walk_module_objects():
|
|
26
|
+
if isinstance(obj, flask.Flask):
|
|
27
|
+
return ("flask", obj)
|
|
28
|
+
except ImportError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
return (None, None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _walk_module_objects():
|
|
35
|
+
"""Walk loaded modules looking for app instances."""
|
|
36
|
+
seen = set()
|
|
37
|
+
for mod in list(sys.modules.values()):
|
|
38
|
+
if mod is None or mod.__name__.startswith("debug_agent"):
|
|
39
|
+
continue
|
|
40
|
+
for attr_name in dir(mod):
|
|
41
|
+
if attr_name.startswith("_"):
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
obj = getattr(mod, attr_name)
|
|
45
|
+
if id(obj) not in seen:
|
|
46
|
+
seen.add(id(obj))
|
|
47
|
+
yield obj
|
|
48
|
+
except Exception:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@debug_tool("get_routes", "List all registered web routes/endpoints with methods and paths")
|
|
53
|
+
def get_routes() -> dict:
|
|
54
|
+
ftype, app = _find_app()
|
|
55
|
+
if not app:
|
|
56
|
+
return {"error": "No supported web framework app found"}
|
|
57
|
+
|
|
58
|
+
routes = []
|
|
59
|
+
if ftype == "fastapi":
|
|
60
|
+
for route in app.routes:
|
|
61
|
+
if hasattr(route, "methods") and hasattr(route, "path"):
|
|
62
|
+
routes.append({
|
|
63
|
+
"path": route.path,
|
|
64
|
+
"methods": list(route.methods or set()),
|
|
65
|
+
"name": getattr(route, "name", ""),
|
|
66
|
+
"endpoint": getattr(route, "endpoint", "").__name__ if hasattr(getattr(route, "endpoint", ""), "__name__") else "",
|
|
67
|
+
})
|
|
68
|
+
elif ftype == "flask":
|
|
69
|
+
for rule in app.url_map.iter_rules():
|
|
70
|
+
routes.append({
|
|
71
|
+
"path": str(rule),
|
|
72
|
+
"methods": sorted(rule.methods - {"HEAD", "OPTIONS"}),
|
|
73
|
+
"endpoint": rule.endpoint,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
return {"framework": ftype, "route_count": len(routes), "routes": routes}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@debug_tool("get_middleware", "List registered middleware (FastAPI/Starlette/FastAPI)")
|
|
80
|
+
def get_middleware() -> dict:
|
|
81
|
+
ftype, app = _find_app()
|
|
82
|
+
if not app:
|
|
83
|
+
return {"error": "No supported web framework app found"}
|
|
84
|
+
|
|
85
|
+
middlewares = []
|
|
86
|
+
if ftype == "fastapi":
|
|
87
|
+
try:
|
|
88
|
+
mw_stack = app.user_middleware
|
|
89
|
+
middlewares = [str(m) for m in mw_stack]
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
elif ftype == "flask":
|
|
93
|
+
middlewares = [] # Flask doesn't have middleware in the same way
|
|
94
|
+
|
|
95
|
+
return {"framework": ftype, "middlewares": middlewares}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@debug_tool("get_installed_packages", "List installed Python packages")
|
|
99
|
+
def get_installed_packages(prefix: str = ToolParam("Filter by prefix (empty = all)", required=False)) -> dict:
|
|
100
|
+
try:
|
|
101
|
+
import pkg_resources
|
|
102
|
+
pkgs = sorted(
|
|
103
|
+
[(p.project_name, p.version) for p in pkg_resources.working_set],
|
|
104
|
+
key=lambda x: x[0].lower(),
|
|
105
|
+
)
|
|
106
|
+
if prefix:
|
|
107
|
+
pkgs = [(n, v) for n, v in pkgs if n.lower().startswith(prefix.lower())]
|
|
108
|
+
return {"total": len(pkgs), "packages": [{"name": n, "version": v} for n, v in pkgs]}
|
|
109
|
+
except ImportError:
|
|
110
|
+
from importlib.metadata import distributions
|
|
111
|
+
pkgs = sorted(
|
|
112
|
+
[(d.metadata["Name"], d.version) for d in distributions()],
|
|
113
|
+
key=lambda x: x[0].lower(),
|
|
114
|
+
)
|
|
115
|
+
if prefix:
|
|
116
|
+
pkgs = [(n, v) for n, v in pkgs if n.lower().startswith(prefix.lower())]
|
|
117
|
+
return {"total": len(pkgs), "packages": [{"name": n, "version": v} for n, v in pkgs]}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@debug_tool("get_environment_variables", "List environment variables (filtered)")
|
|
121
|
+
def get_environment_variables(prefix: str = ToolParam("Filter by prefix (e.g. 'PATH')", required=False)) -> dict:
|
|
122
|
+
import os
|
|
123
|
+
env = dict(os.environ)
|
|
124
|
+
if prefix:
|
|
125
|
+
env = {k: v for k, v in env.items() if k.upper().startswith(prefix.upper())}
|
|
126
|
+
# Mask potential secrets
|
|
127
|
+
masked = {}
|
|
128
|
+
for k, v in env.items():
|
|
129
|
+
if any(s in k.upper() for s in ["KEY", "SECRET", "PASSWORD", "TOKEN", "CREDENTIAL"]):
|
|
130
|
+
masked[k] = "***masked***"
|
|
131
|
+
else:
|
|
132
|
+
masked[k] = v
|
|
133
|
+
return {"variables": masked, "count": len(masked)}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""HTTP request tracker: in-memory ring buffer for recent requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import threading
|
|
7
|
+
from collections import deque
|
|
8
|
+
|
|
9
|
+
from debug_agent.tool_registry import debug_tool, ToolParam
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Global ring buffer for request tracking
|
|
13
|
+
_buffer_lock = threading.Lock()
|
|
14
|
+
_request_buffer: deque = deque(maxlen=500)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def record_request(method: str, path: str, status: int, duration_ms: float, client: str = ""):
|
|
18
|
+
"""Record an HTTP request. Call this from your middleware."""
|
|
19
|
+
with _buffer_lock:
|
|
20
|
+
_request_buffer.append({
|
|
21
|
+
"timestamp": time.time(),
|
|
22
|
+
"method": method,
|
|
23
|
+
"path": path,
|
|
24
|
+
"status": status,
|
|
25
|
+
"duration_ms": round(duration_ms, 2),
|
|
26
|
+
"client": client,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_all() -> list[dict]:
|
|
31
|
+
with _buffer_lock:
|
|
32
|
+
return list(_request_buffer)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@debug_tool("get_recent_requests", "Get recent HTTP requests from the in-memory ring buffer")
|
|
36
|
+
def get_recent_requests(limit: int = ToolParam("Max results to return", required=False)) -> dict:
|
|
37
|
+
reqs = _get_all()
|
|
38
|
+
if limit:
|
|
39
|
+
reqs = reqs[-limit:]
|
|
40
|
+
return {"total": len(_request_buffer), "requests": list(reversed(reqs))}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@debug_tool("get_slow_requests", "Get slowest HTTP requests sorted by duration")
|
|
44
|
+
def get_slow_requests(threshold_ms: float = ToolParam("Minimum duration in ms", required=False)) -> dict:
|
|
45
|
+
reqs = _get_all()
|
|
46
|
+
if threshold_ms:
|
|
47
|
+
reqs = [r for r in reqs if r["duration_ms"] >= threshold_ms]
|
|
48
|
+
reqs.sort(key=lambda x: -x["duration_ms"])
|
|
49
|
+
return {"count": len(reqs), "requests": reqs[:20]}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@debug_tool("get_error_requests", "Get all error requests (4xx/5xx status codes)")
|
|
53
|
+
def get_error_requests() -> dict:
|
|
54
|
+
reqs = [r for r in _get_all() if r["status"] >= 400]
|
|
55
|
+
reqs.sort(key=lambda x: -x["duration_ms"])
|
|
56
|
+
return {"count": len(reqs), "requests": reqs}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@debug_tool("get_request_stats", "Get HTTP request statistics: count, P50/P95/P99 latency, error rate")
|
|
60
|
+
def get_request_stats() -> dict:
|
|
61
|
+
import math
|
|
62
|
+
|
|
63
|
+
reqs = _get_all()
|
|
64
|
+
if not reqs:
|
|
65
|
+
return {"message": "No requests recorded yet"}
|
|
66
|
+
|
|
67
|
+
durations = sorted([r["duration_ms"] for r in reqs])
|
|
68
|
+
n = len(durations)
|
|
69
|
+
|
|
70
|
+
def percentile(p: float) -> float:
|
|
71
|
+
idx = min(int(math.ceil(p * n)) - 1, n - 1)
|
|
72
|
+
return round(durations[idx], 2)
|
|
73
|
+
|
|
74
|
+
errors = sum(1 for r in reqs if r["status"] >= 400)
|
|
75
|
+
|
|
76
|
+
# Group by path
|
|
77
|
+
by_path: dict[str, int] = {}
|
|
78
|
+
for r in reqs:
|
|
79
|
+
by_path[r["path"]] = by_path.get(r["path"], 0) + 1
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"total_requests": n,
|
|
83
|
+
"error_count": errors,
|
|
84
|
+
"error_rate": f"{errors / n * 100:.1f}%",
|
|
85
|
+
"latency_ms": {
|
|
86
|
+
"min": durations[0],
|
|
87
|
+
"p50": percentile(0.5),
|
|
88
|
+
"p95": percentile(0.95),
|
|
89
|
+
"p99": percentile(0.99),
|
|
90
|
+
"max": durations[-1],
|
|
91
|
+
},
|
|
92
|
+
"top_paths": dict(sorted(by_path.items(), key=lambda x: -x[1])[:10]),
|
|
93
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Memory profiler inspector: tracemalloc, object counts, GC details, reference cycles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gc
|
|
6
|
+
import tracemalloc
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from debug_agent.tool_registry import debug_tool, ToolParam
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@debug_tool(
|
|
14
|
+
"get_tracemalloc_stats",
|
|
15
|
+
"Get Python tracemalloc statistics: top allocations by file/line, traced memory",
|
|
16
|
+
)
|
|
17
|
+
def get_tracemalloc_stats(limit: int = ToolParam("Max number of top allocations to return", required=False)) -> dict:
|
|
18
|
+
if not tracemalloc.is_tracing():
|
|
19
|
+
try:
|
|
20
|
+
tracemalloc.start(10)
|
|
21
|
+
except RuntimeError:
|
|
22
|
+
return {"error": "tracemalloc could not be started"}
|
|
23
|
+
|
|
24
|
+
snapshot = tracemalloc.take_snapshot()
|
|
25
|
+
top = snapshot.statistics("lineno")
|
|
26
|
+
current, peak = tracemalloc.get_traced_memory()
|
|
27
|
+
|
|
28
|
+
allocations = []
|
|
29
|
+
n = limit if limit and limit > 0 else 20
|
|
30
|
+
for stat in top[:n]:
|
|
31
|
+
frame = stat.traceback[0]
|
|
32
|
+
allocations.append({
|
|
33
|
+
"file": frame.filename,
|
|
34
|
+
"line": frame.lineno,
|
|
35
|
+
"size_kb": round(stat.size / 1024, 2),
|
|
36
|
+
"count": stat.count,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
"traced_memory_mb": round(current / 1024 / 1024, 2),
|
|
41
|
+
"peak_memory_mb": round(peak / 1024 / 1024, 2),
|
|
42
|
+
"top_allocations": allocations,
|
|
43
|
+
"total_traced_blocks": sum(s.count for s in top),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@debug_tool(
|
|
48
|
+
"get_object_counts",
|
|
49
|
+
"Count live Python objects by type via gc.get_objects() summary",
|
|
50
|
+
)
|
|
51
|
+
def get_object_counts(limit: int = ToolParam("Max number of type entries to return", required=False)) -> dict:
|
|
52
|
+
all_objects = gc.get_objects()
|
|
53
|
+
type_counter: Counter = Counter()
|
|
54
|
+
for obj in all_objects:
|
|
55
|
+
type_counter[type(obj).__name__] += 1
|
|
56
|
+
|
|
57
|
+
n = limit if limit and limit > 0 else 20
|
|
58
|
+
top_types = type_counter.most_common(n)
|
|
59
|
+
return {
|
|
60
|
+
"total_objects": len(all_objects),
|
|
61
|
+
"unique_types": len(type_counter),
|
|
62
|
+
"top_types": {t: c for t, c in top_types},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@debug_tool(
|
|
67
|
+
"get_gc_stats",
|
|
68
|
+
"Get garbage collector statistics: collections, collected, and uncollectable counts per generation",
|
|
69
|
+
)
|
|
70
|
+
def get_gc_stats() -> dict:
|
|
71
|
+
stats = gc.get_stats()
|
|
72
|
+
threshold = gc.get_threshold()
|
|
73
|
+
return {
|
|
74
|
+
"generations": [
|
|
75
|
+
{
|
|
76
|
+
"generation": i,
|
|
77
|
+
"collections": s["collections"],
|
|
78
|
+
"collected": s["collected"],
|
|
79
|
+
"uncollectable": s["uncollectable"],
|
|
80
|
+
}
|
|
81
|
+
for i, s in enumerate(stats)
|
|
82
|
+
],
|
|
83
|
+
"thresholds": {"gen0": threshold[0], "gen1": threshold[1], "gen2": threshold[2]},
|
|
84
|
+
"current_counts": {"gen0": gc.get_count()[0], "gen1": gc.get_count()[1], "gen2": gc.get_count()[2]},
|
|
85
|
+
"garbage_list_size": len(gc.garbage),
|
|
86
|
+
"debug_flags": gc.get_debug(),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@debug_tool(
|
|
91
|
+
"get_ref_cycles",
|
|
92
|
+
"Count reference cycles detected by the garbage collector",
|
|
93
|
+
)
|
|
94
|
+
def get_ref_cycles() -> dict:
|
|
95
|
+
# Save current debug flags
|
|
96
|
+
old_flags = gc.get_debug()
|
|
97
|
+
gc.set_debug(gc.DEBUG_SAVEALL)
|
|
98
|
+
|
|
99
|
+
before = len(gc.get_objects())
|
|
100
|
+
collected = gc.collect()
|
|
101
|
+
after = len(gc.get_objects())
|
|
102
|
+
|
|
103
|
+
# Analyze what was collected (stored in gc.garbage when DEBUG_SAVEALL)
|
|
104
|
+
cycle_types: Counter = Counter()
|
|
105
|
+
for obj in gc.garbage:
|
|
106
|
+
cycle_types[type(obj).__name__] += 1
|
|
107
|
+
|
|
108
|
+
# Restore
|
|
109
|
+
gc.set_debug(old_flags)
|
|
110
|
+
gc.garbage.clear()
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"objects_collected": collected,
|
|
114
|
+
"objects_freed": before - after,
|
|
115
|
+
"reference_cycle_types": dict(cycle_types.most_common(15)),
|
|
116
|
+
"total_cycles_found": sum(cycle_types.values()),
|
|
117
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Module inspector: loaded modules, import stats, module details."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from debug_agent.tool_registry import debug_tool, ToolParam
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@debug_tool(
|
|
12
|
+
"get_loaded_modules",
|
|
13
|
+
"List loaded Python modules (sys.modules) with their versions",
|
|
14
|
+
)
|
|
15
|
+
def get_loaded_modules(prefix: str = ToolParam("Filter by module name prefix (empty = all)", required=False)) -> dict:
|
|
16
|
+
results = []
|
|
17
|
+
for name in sorted(sys.modules.keys()):
|
|
18
|
+
if prefix and not name.lower().startswith(prefix.lower()):
|
|
19
|
+
continue
|
|
20
|
+
mod = sys.modules.get(name)
|
|
21
|
+
if mod is None:
|
|
22
|
+
results.append({"name": name, "version": None, "loaded": False})
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
version = getattr(mod, "__version__", None)
|
|
26
|
+
if not version:
|
|
27
|
+
# Try importlib.metadata for installed packages
|
|
28
|
+
try:
|
|
29
|
+
from importlib.metadata import version as get_version
|
|
30
|
+
# Handle submodules by taking the top-level package
|
|
31
|
+
top_pkg = name.split(".")[0]
|
|
32
|
+
version = get_version(top_pkg)
|
|
33
|
+
except Exception:
|
|
34
|
+
version = None
|
|
35
|
+
|
|
36
|
+
results.append({"name": name, "version": version, "file": getattr(mod, "__file__", None)})
|
|
37
|
+
|
|
38
|
+
return {"total": len(results), "modules": results}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@debug_tool(
|
|
42
|
+
"get_import_stats",
|
|
43
|
+
"Get module import statistics: total count, largest modules by file size",
|
|
44
|
+
)
|
|
45
|
+
def get_import_stats() -> dict:
|
|
46
|
+
import os
|
|
47
|
+
|
|
48
|
+
module_sizes = []
|
|
49
|
+
total = 0
|
|
50
|
+
builtin_count = 0
|
|
51
|
+
|
|
52
|
+
for name, mod in sys.modules.items():
|
|
53
|
+
if mod is None:
|
|
54
|
+
continue
|
|
55
|
+
total += 1
|
|
56
|
+
filepath = getattr(mod, "__file__", None)
|
|
57
|
+
if filepath and os.path.isfile(filepath):
|
|
58
|
+
try:
|
|
59
|
+
size = os.path.getsize(filepath)
|
|
60
|
+
module_sizes.append((name, size, filepath))
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
else:
|
|
64
|
+
builtin_count += 1
|
|
65
|
+
|
|
66
|
+
module_sizes.sort(key=lambda x: -x[1])
|
|
67
|
+
largest = [
|
|
68
|
+
{"name": name, "size_kb": round(size / 1024, 2), "file": filepath}
|
|
69
|
+
for name, size, filepath in module_sizes[:20]
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"total_modules": total,
|
|
74
|
+
"builtin_or_builtin_like": builtin_count,
|
|
75
|
+
"with_file_path": len(module_sizes),
|
|
76
|
+
"largest_modules": largest,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@debug_tool(
|
|
81
|
+
"get_module_detail",
|
|
82
|
+
"Get detailed information about a specific loaded module",
|
|
83
|
+
)
|
|
84
|
+
def get_module_detail(module_name: str = ToolParam("The module name to inspect (e.g. 'flask', 'debug_agent.engine')")) -> dict:
|
|
85
|
+
mod = sys.modules.get(module_name)
|
|
86
|
+
if mod is None:
|
|
87
|
+
return {"error": f"Module '{module_name}' is not loaded"}
|
|
88
|
+
|
|
89
|
+
import os
|
|
90
|
+
filepath = getattr(mod, "__file__", None)
|
|
91
|
+
file_size = None
|
|
92
|
+
if filepath and os.path.isfile(filepath):
|
|
93
|
+
try:
|
|
94
|
+
file_size = os.path.getsize(filepath)
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
version = getattr(mod, "__version__", None)
|
|
99
|
+
if not version:
|
|
100
|
+
try:
|
|
101
|
+
from importlib.metadata import version as get_version
|
|
102
|
+
version = get_version(module_name.split(".")[0])
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
# Get public attributes
|
|
107
|
+
public_attrs = []
|
|
108
|
+
for attr in sorted(dir(mod)):
|
|
109
|
+
if attr.startswith("_"):
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
obj = getattr(mod, attr)
|
|
113
|
+
public_attrs.append({
|
|
114
|
+
"name": attr,
|
|
115
|
+
"type": type(obj).__name__,
|
|
116
|
+
})
|
|
117
|
+
except Exception:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"name": module_name,
|
|
122
|
+
"file": filepath,
|
|
123
|
+
"file_size_bytes": file_size,
|
|
124
|
+
"version": version,
|
|
125
|
+
"package": getattr(mod, "__package__", None),
|
|
126
|
+
"spec": str(getattr(mod, "__spec__", None)),
|
|
127
|
+
"public_attribute_count": len(public_attrs),
|
|
128
|
+
"public_attributes": public_attrs[:50],
|
|
129
|
+
}
|