python-debugpy
Debug Python: pdb REPL + debugpy remote (DAP).
Debug Python: pdb REPL + debugpy remote (DAP).
Real data. Real impact.
Emerging
Developers
Per week
Excellent
Skills give you superpowers. Install in 30 seconds.
Three tools, picked by situation:
| Tool | When |
|---|---|
+ pdb | Local, interactive, simplest. Add in the source, run normally, get a REPL at that line. |
| Launch an existing script under pdb with no source edits. Useful for quick poking. |
| Remote / headless / "attach to already-running process." Talks DAP, scriptable from terminal, works for long-lived processes (gateway, daemon, PTY children). |
Start with
. It's the cheapest thing that works.breakpoint()
_SlashWorker, PTY bridge worker) is the actual bug siteDon't use for: things
print() / logging.debug solve in under a minute, or things pytest -vv --tb=long --showlocals already reveals.
Inside any pdb prompt (
(Pdb)):
| Command | Action |
|---|---|
/ | help |
| next line (step over) |
| step into |
| return from current function |
| continue |
| continue until line N |
| jump to line N (same function only) |
/ | list source around current line / full function |
| where (stack trace) |
/ | move up / down in the stack |
| print args of the current function |
/ | print / pretty-print expression |
| auto-print expr on every stop |
| set breakpoint |
| break on function entry |
| conditional breakpoint |
| clear breakpoint N |
| one-shot breakpoint |
| execute arbitrary Python (assignments included) |
| drop into full Python REPL in current scope (Ctrl+D to exit) |
| quit |
The
interact command is the most powerful — you can import anything, inspect complex objects, even call methods that mutate state. Locals are read-only by default; use !x = 42 from the (Pdb) prompt to mutate.
Easiest. Edit the file:
def compute(x, y): result = some_helper(x) breakpoint() # <-- drops into pdb here return result + y
Run the code normally. You land at the
breakpoint() line with full access to locals.
Don't forget to remove
before committing. Use breakpoint()
git diff or a pre-commit grep:
rg -n 'breakpoint\(\)' --type py
python -m pdb path/to/script.py arg1 arg2 # Lands at first line of script (Pdb) b path/to/script.py:42 (Pdb) c
The hermes test runner and pytest both support this:
# Drop to pdb on failure (or on any raised exception): scripts/run_tests.sh tests/path/to/test_file.py::test_name --pdb # Drop to pdb at the START of the test: scripts/run_tests.sh tests/path/to/test_file.py::test_name --trace # Show locals in tracebacks without pdb: scripts/run_tests.sh tests/path/to/test_file.py --showlocals --tb=long
Note:
scripts/run_tests.sh uses xdist (-n 4) by default, and pdb does NOT work under xdist. Add -p no:xdist or run a single test with -n 0:
scripts/run_tests.sh tests/foo_test.py::test_bar --pdb -p no:xdist # or source .venv/bin/activate python -m pytest tests/foo_test.py::test_bar --pdb
This bypasses the hermetic-env guarantees — fine for debugging, but re-run under the wrapper to confirm before pushing.
import pdb, sys try: run_the_thing() except Exception: pdb.post_mortem(sys.exc_info()[2])
Or wrap a whole script:
python -m pdb -c continue script.py # When it crashes, pdb catches it and you're in the frame of the exception
Or set a global hook in a repl/jupyter:
import sys def excepthook(etype, value, tb): import pdb; pdb.post_mortem(tb) sys.excepthook = excepthook
For long-lived processes: Hermes gateway, tui_gateway, a daemon, a process that's already misbehaving and can't be restarted clean.
source /home/bb/hermes-agent/.venv/bin/activate pip install debugpy
Add near the top of the entry point (or inside the function you want to debug):
import debugpy debugpy.listen(("127.0.0.1", 5678)) print("debugpy listening on 5678, waiting for client...", flush=True) debugpy.wait_for_client() debugpy.breakpoint() # optional: pause immediately once attached
Start the process; it blocks on
wait_for_client().
-m debugpypython -m debugpy --listen 127.0.0.1:5678 --wait-for-client your_script.py arg1
Equivalent for module entry:
python -m debugpy --listen 127.0.0.1:5678 --wait-for-client -m your.module
Needs the PID and debugpy preinstalled in the target's environment:
python -m debugpy --listen 127.0.0.1:5678 --pid <pid> # debugpy injects itself into the process. Then attach a client as below.
Some kernels/security configs block the ptrace-based injection (
/proc/sys/kernel/yama/ptrace_scope). Fix with:
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
The easiest terminal-side DAP client is VS Code CLI or a small script. From inside Hermes you have two practical options:
Option 1:
's own CLI REPL — not an official feature, but a tiny DAP client script:debugpy
# /tmp/dap_client.py import socket, json, itertools, time, sys HOST, PORT = "127.0.0.1", 5678 s = socket.create_connection((HOST, PORT)) seq = itertools.count(1) def send(msg): msg["seq"] = next(seq) body = json.dumps(msg).encode() s.sendall(f"Content-Length: {len(body)}\r\n\r\n".encode() + body) def recv(): header = b"" while b"\r\n\r\n" not in header: header += s.recv(1) length = int(header.decode().split("Content-Length:")[1].split("\r\n")[0].strip()) body = b"" while len(body) < length: body += s.recv(length - len(body)) return json.loads(body) send({"type": "request", "command": "initialize", "arguments": {"adapterID": "python"}}) print(recv()) send({"type": "request", "command": "attach", "arguments": {}}) print(recv()) send({"type": "request", "command": "setBreakpoints", "arguments": {"source": {"path": sys.argv[1]}, "breakpoints": [{"line": int(sys.argv[2])}]}}) print(recv()) send({"type": "request", "command": "configurationDone"}) # ... loop reading events and sending continue/stepIn/etc.
This is fine for one-off automation but painful as an interactive UX.
Option 2: Attach from VS Code / Cursor / Zed — if the user has one open, they can add a
launch.json:
{ "name": "Attach to Hermes", "type": "debugpy", "request": "attach", "connect": { "host": "127.0.0.1", "port": 5678 }, "justMyCode": false, "pathMappings": [ { "localRoot": "${workspaceFolder}", "remoteRoot": "/home/bb/hermes-agent" } ] }
Option 3: Ditch DAP, use
— usually what you actually want from a terminal agent:remote-pdb
pip install remote-pdb
In your code:
from remote_pdb import set_trace set_trace(host="127.0.0.1", port=4444) # blocks until connection
Then from the terminal:
nc 127.0.0.1 4444 # You get a (Pdb) prompt exactly as if debugging locally.
remote-pdb is the cleanest agent-friendly choice when debugpy's DAP protocol is overkill. Use debugpy only when you actually need IDE integration.
See Recipe 3. Always add
-p no:xdist or run single tests without xdist.
run_agent.py / CLI — one-shotEasiest: add
breakpoint() near the suspect line, then run hermes normally. Control returns to your terminal at the pause point.
tui_gateway subprocess (spawned by hermes --tui)The gateway runs as a child of the Node TUI. Options:
A. Source-edit the gateway:
# tui_gateway/server.py near the top of serve() import debugpy debugpy.listen(("127.0.0.1", 5678)) debugpy.wait_for_client()
Start
hermes --tui. The TUI will appear frozen (its backend is waiting). Attach a client; execution resumes when you continue.
B. Use
at a specific handler:remote-pdb
from remote_pdb import set_trace set_trace(host="127.0.0.1", port=4444) # in the RPC handler you want to trap
Trigger the matching slash command from the TUI, then
nc 127.0.0.1 4444 in another terminal.
_SlashWorker subprocessSame pattern —
remote-pdb with set_trace() inside the worker's exec path. The worker is persistent across slash commands, so the first trigger blocks until you connect; subsequent slash commands pass through normally unless you re-arm.
gateway/run.py)Long-lived. Use
remote-pdb at a handler, or debugpy with --wait-for-client if you're restarting the gateway anyway.
pdb under pytest-xdist silently does nothing. You won't see the prompt, the test just hangs. Always use
-p no:xdist or -n 0.
in CI / non-TTY contexts hangs the process. Safe locally; never commit it. Add a pre-commit grep as a safety net.breakpoint()
disables all PYTHONBREAKPOINT=0
breakpoint() calls. Check the env if your breakpoint isn't hitting:
echo $PYTHONBREAKPOINT
blocks only if you also call debugpy.listen
. Without it, execution continues and your first breakpoint may fire before the client is attached.wait_for_client()
Attach to PID fails on hardened kernels.
ptrace_scope=1 (Ubuntu default) allows only same-user ptrace of child processes. Workaround: echo 0 > /proc/sys/kernel/yama/ptrace_scope (needs root) or launch under debugpy from the start.
Threads.
pdb only debugs the current thread. For multithreaded code, use debugpy (thread-aware DAP) or set threading.settrace() per thread.
asyncio.
pdb works in coroutines but await inside pdb requires Python 3.13+ or await from interact mode on older versions. For 3.11/3.12, use asyncio.run_coroutine_threadsafe tricks or !stmt-based awaits via asyncio.ensure_future.
strips credentials and sets scripts/run_tests.sh
. If your bug depends on user config or real API keys, it won't reproduce under the wrapper. Debug with raw HOME=<tmpdir>
pytest first to repro, then re-confirm under the wrapper.
Forking / multiprocessing. pdb does not follow forks. Each child needs its own
breakpoint() or set_trace(). For Hermes subagents, debug one process at a time.
pip install debugpy, confirm: python -c "import debugpy; print(debugpy.__version__)"ss -tlnp | grep 5678PYTHONBREAKPOINT=0, you're under xdist, or execution finished before attach)where / w shows the expected call stackbreakpoint() / set_trace() in committed code
rg -n 'breakpoint\(\)|set_trace\(|debugpy\.listen' --type py
"Why is this dict missing a key?"
# add above the KeyError site breakpoint() # then in pdb: (Pdb) pp d (Pdb) pp list(d.keys()) (Pdb) w # how did we get here
"This test passes in isolation but fails in the suite."
scripts/run_tests.sh tests/the_test.py --pdb -p no:xdist # But if it only fails WITH other tests: source .venv/bin/activate python -m pytest tests/ -x --pdb -p no:xdist # Now it pdb-traps at the exact failing test after state accumulated.
"My async handler deadlocks."
# Add at handler entry import remote_pdb; remote_pdb.set_trace(host="127.0.0.1", port=4444)
Trigger the handler.
nc 127.0.0.1 4444, then w to see the suspended frame, !import asyncio; asyncio.all_tasks() to see what else is pending.
"Post-mortem on a crash in an Ink child process / subprocess."
PYTHONFAULTHANDLER=1 python -m pdb -c continue path/to/entrypoint.py # On crash, pdb lands at the frame of the exception with full locals
MIT
mkdir -p ~/.hermes/skills/software-development/python-debugpy && curl -o ~/.hermes/skills/software-development/python-debugpy/SKILL.md https://raw.githubusercontent.com/NousResearch/hermes-agent/main/skills/software-development/python-debugpy/SKILL.md1,500+ AI skills, agents & workflows. Install in 30 seconds. Part of the Torly.ai family.
© 2026 Torly.ai. All rights reserved.