python-debugpy
Original:🇺🇸 English
Translated
Debug Python: pdb REPL + debugpy remote (DAP).
4installs
Added on
NPX Install
npx skill4agent add nousresearch/hermes-agent python-debugpyTags
Translated version includes tags in frontmatterSKILL.md Content
View Translation Comparison →Python Debugger (pdb + debugpy)
Overview
Three tools, picked by situation:
| Tool | When |
|---|---|
| Local, interactive, simplest. Add |
| 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()When to Use
- A test fails and the traceback doesn't reveal why a value is wrong
- You need to step through a function and watch a collection mutate
- A long-running process (hermes gateway, tui_gateway) misbehaves and you can't restart it
- Post-mortem: an exception fired in prod-ish code and you want to inspect locals at the crash site
- A subprocess / child (Python , PTY bridge worker) is the actual bug site
_SlashWorker
Don't use for: things / solve in under a minute, or things already reveals.
print()logging.debugpytest -vv --tb=long --showlocalspdb Quick Reference
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 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 from the prompt to mutate.
interact!x = 42(Pdb)Recipe 1: Local breakpoint
Easiest. Edit the file:
python
def compute(x, y):
result = some_helper(x)
breakpoint() # <-- drops into pdb here
return result + yRun the code normally. You land at the line with full access to locals.
breakpoint()Don't forget to remove before committing. Use or a pre-commit grep:
breakpoint()git diffbash
rg -n 'breakpoint\(\)' --type pyRecipe 2: Launch a script under pdb (no source edits)
bash
python -m pdb path/to/script.py arg1 arg2
# Lands at first line of script
(Pdb) b path/to/script.py:42
(Pdb) cRecipe 3: Debug a pytest test
The hermes test runner and pytest both support this:
bash
# 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=longNote: uses xdist () by default, and pdb does NOT work under xdist. Add or run a single test with :
scripts/run_tests.sh-n 4-p no:xdist-n 0bash
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 --pdbThis bypasses the hermetic-env guarantees — fine for debugging, but re-run under the wrapper to confirm before pushing.
Recipe 4: Post-mortem on any exception
python
import pdb, sys
try:
run_the_thing()
except Exception:
pdb.post_mortem(sys.exc_info()[2])Or wrap a whole script:
bash
python -m pdb -c continue script.py
# When it crashes, pdb catches it and you're in the frame of the exceptionOr set a global hook in a repl/jupyter:
python
import sys
def excepthook(etype, value, tb):
import pdb; pdb.post_mortem(tb)
sys.excepthook = excepthookRecipe 5: Remote debug with debugpy (attach to running process)
For long-lived processes: Hermes gateway, tui_gateway, a daemon, a process that's already misbehaving and can't be restarted clean.
Setup
bash
source /home/bb/hermes-agent/.venv/bin/activate
pip install debugpyPattern A: Source-edit — process waits for debugger at launch
Add near the top of the entry point (or inside the function you want to debug):
python
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 attachedStart the process; it blocks on .
wait_for_client()Pattern B: No source edit — launch with -m debugpy
-m debugpybash
python -m debugpy --listen 127.0.0.1:5678 --wait-for-client your_script.py arg1Equivalent for module entry:
bash
python -m debugpy --listen 127.0.0.1:5678 --wait-for-client -m your.modulePattern C: Attach to an already-running process
Needs the PID and debugpy preinstalled in the target's environment:
bash
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 (). Fix with:
/proc/sys/kernel/yama/ptrace_scopebash
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scopeConnecting a client from the terminal
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:
debugpypython
# /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.jsonjson
{
"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-pdbbash
pip install remote-pdbIn your code:
python
from remote_pdb import set_trace
set_trace(host="127.0.0.1", port=4444) # blocks until connectionThen from the terminal:
bash
nc 127.0.0.1 4444
# You get a (Pdb) prompt exactly as if debugging locally.remote-pdbdebugpydebugpyDebugging Hermes-specific Processes
Tests
See Recipe 3. Always add or run single tests without xdist.
-p no:xdistrun_agent.py
/ CLI — one-shot
run_agent.pyEasiest: add near the suspect line, then run normally. Control returns to your terminal at the pause point.
breakpoint()hermestui_gateway
subprocess (spawned by hermes --tui
)
tui_gatewayhermes --tuiThe gateway runs as a child of the Node TUI. Options:
A. Source-edit the gateway:
python
# tui_gateway/server.py near the top of serve()
import debugpy
debugpy.listen(("127.0.0.1", 5678))
debugpy.wait_for_client()Start . The TUI will appear frozen (its backend is waiting). Attach a client; execution resumes when you .
hermes --tuicontinueB. Use at a specific handler:
remote-pdbpython
from remote_pdb import set_trace
set_trace(host="127.0.0.1", port=4444) # in the RPC handler you want to trapTrigger the matching slash command from the TUI, then in another terminal.
nc 127.0.0.1 4444_SlashWorker
subprocess
_SlashWorkerSame pattern — with inside the worker's 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.
remote-pdbset_trace()execGateway (gateway/run.py
)
gateway/run.pyLong-lived. Use at a handler, or with if you're restarting the gateway anyway.
remote-pdbdebugpy--wait-for-clientCommon Pitfalls
-
pdb under pytest-xdist silently does nothing. You won't see the prompt, the test just hangs. Always useor
-p no:xdist.-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=0calls. Check the env if your breakpoint isn't hitting:breakpoint()bashecho $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.(Ubuntu default) allows only same-user ptrace of child processes. Workaround:
ptrace_scope=1(needs root) or launch underecho 0 > /proc/sys/kernel/yama/ptrace_scopefrom the start.debugpy -
Threads.only debugs the current thread. For multithreaded code, use
pdb(thread-aware DAP) or setdebugpyper thread.threading.settrace() -
asyncio.works in coroutines but
pdbinside pdb requires Python 3.13+ orawaitfromawaitmode on older versions. For 3.11/3.12, useinteracttricks orasyncio.run_coroutine_threadsafe-based awaits via!stmt.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 rawHOME=<tmpdir>first to repro, then re-confirm under the wrapper.pytest -
Forking / multiprocessing. pdb does not follow forks. Each child needs its ownor
breakpoint(). For Hermes subagents, debug one process at a time.set_trace()
Verification Checklist
- After , confirm:
pip install debugpypython -c "import debugpy; print(debugpy.__version__)" - For remote debug, confirm the port is actually listening:
ss -tlnp | grep 5678 - First breakpoint actually hits (if it doesn't, you likely have , you're under xdist, or execution finished before attach)
PYTHONBREAKPOINT=0 - /
whereshows the expected call stackw - Post-debug cleanup: no stray /
breakpoint()in committed codeset_trace()bashrg -n 'breakpoint\(\)|set_trace\(|debugpy\.listen' --type py
One-Shot Recipes
"Why is this dict missing a key?"
python
# 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."
bash
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."
python
# Add at handler entry
import remote_pdb; remote_pdb.set_trace(host="127.0.0.1", port=4444)Trigger the handler. , then to see the suspended frame, to see what else is pending.
nc 127.0.0.1 4444w!import asyncio; asyncio.all_tasks()"Post-mortem on a crash in an Ink child process / subprocess."
bash
PYTHONFAULTHANDLER=1 python -m pdb -c continue path/to/entrypoint.py
# On crash, pdb lands at the frame of the exception with full locals