Debug Python with pdb and debugpy in Hermes Agent
Debug Python: pdb REPL + debugpy remote (DAP).
Written by Neura Market from the official Hermes Agent documentation for Python Debugpy. Commands, paths, and version numbers are reproduced from the source unchanged.
Read the official documentationThis skill gives you three ways to debug Python code inside Hermes Agent: the built-in breakpoint() + pdb REPL for local work, python -m pdb for launching scripts without edits, and debugpy for remote or headless debugging via the Debug Adapter Protocol. Reach for it when a traceback is not enough, a long-running process misbehaves, or you need to inspect state at a crash site.
What it does
You get a full interactive debugger at the point of failure. Add a single line to your source, run the code, and you land in a pdb prompt where you can step through lines, inspect variables, evaluate expressions, and even mutate state. For processes you cannot restart or that run on a remote machine, debugpy lets you attach a debugger client over TCP. The skill covers both local and remote scenarios, including post-mortem debugging after an exception and debugging subprocesses spawned by Hermes itself.
Before you start
- Platforms: Linux and macOS. Windows is not supported.
- Skill path:
skills/software-development/python-debugpy - Version: 1.0.0 (bundled with Hermes Agent, installed by default)
- License: MIT
- Author: Hermes Agent
- Prerequisites: A working Python install with
breakpoint()available. For remote debugging, installdebugpyvia pip. Theremote-pdbpackage is an optional alternative. - Permissions: Attaching to an already-running process with
--pidmay require adjustingptrace_scopeon Linux (see Limits and gotchas).
pdb quick reference
Inside any pdb prompt ((Pdb)):
| Command | Action |
|---|---|
h / h cmd | help |
n | next line (step over) |
s | step into |
r | return from current function |
c | continue |
unt N | continue until line N |
j N | jump to line N (same function only) |
l / ll | list source around current line / full function |
w | where (stack trace) |
u / d | move up / down in the stack |
a | print args of the current function |
p expr / pp expr | print / pretty-print expression |
display expr | auto-print expr on every stop |
b file:line | set breakpoint |
b func | break on function entry |
b file:line, cond | conditional breakpoint |
cl N | clear breakpoint N |
tbreak file:line | one-shot breakpoint |
!stmt | execute arbitrary Python (assignments included) |
interact | drop into full Python REPL in current scope (Ctrl+D to exit) |
q | 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.
Recipe 1: Local breakpoint
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 breakpoint() before committing. Use git diff or a pre-commit grep:
rg -n 'breakpoint\(\)' --type py
Recipe 2: Launch a script under pdb (no source edits)
python -m pdb path/to/script.py arg1 arg2
# Lands at first line of script
(Pdb) b path/to/script.py:42
(Pdb) c
Recipe 3: Debug a pytest test
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.
Recipe 4: Post-mortem on any exception
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
Recipe 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
source /home/bb/hermes-agent/.venv/bin/activate
pip install debugpy
Pattern 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):
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().
Pattern B: No source edit, launch with -m debugpy
python -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
Pattern C: Attach to an already-running process
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
Connecting 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: debugpy's own CLI REPL, not an official feature, but a tiny DAP client script:
# /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 remote-pdb, usually what you actually want from a terminal agent:
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.
Debugging Hermes-specific processes
Tests
See Recipe 3. Always add -p no:xdist or run single tests without xdist.
run_agent.py / CLI, one-shot
Easiest: 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 remote-pdb at a specific handler:
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 subprocess
Same 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 (gateway/run.py)
Long-lived. Use remote-pdb at a handler, or debugpy with --wait-for-client if you're restarting the gateway anyway.
Common pitfalls
- pdb under pytest-xdist silently does nothing. You won't see the prompt, the test just hangs. Always use
-p no:xdistor-n 0. breakpoint()in CI / non-TTY contexts hangs the process. Safe locally; never commit it. Add a pre-commit grep as a safety net.PYTHONBREAKPOINT=0disables allbreakpoint()calls. Check the env if your breakpoint isn't hitting:
echo $PYTHONBREAKPOINT
debugpy.listenblocks only if you also callwait_for_client(). Without it, execution continues and your first breakpoint may fire before the client is attached.- 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 underdebugpyfrom the start. - Threads.
pdbonly debugs the current thread. For multithreaded code, usedebugpy(thread-aware DAP) or setthreading.settrace()per thread. - asyncio.
pdbworks in coroutines butawaitinside pdb requires Python 3.13+ orawaitfrominteractmode on older versions. For 3.11/3.12, useasyncio.run_coroutine_threadsafetricks or!stmt-based awaits viaasyncio.ensure_future. scripts/run_tests.shstrips credentials and setsHOME=. If your bug depends on user config or real API keys, it won't reproduce under the wrapper. Debug with rawpytestfirst to repro, then re-confirm under the wrapper.- Forking / multiprocessing. pdb does not follow forks. Each child needs its own
breakpoint()orset_trace(). For Hermes subagents, debug one process at a time.
Verification checklist
- After
pip install debugpy, confirm:python -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
PYTHONBREAKPOINT=0, you're under xdist, or execution finished before attach) where/wshows the expected call stack- Post-debug cleanup: no stray
breakpoint()/set_trace()in committed code
rg -n 'breakpoint\(\)|set_trace\(|debugpy\.listen' --type py
One-shot recipes
"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
When not to use it
Do not reach for this skill when a simple print() or logging.debug statement can reveal the value in under a minute. Also skip it if pytest -vv --tb=long --showlocals already shows you enough context from the traceback. The debugger is overkill for trivial state inspection.
Limits and gotchas
- pdb under pytest-xdist silently does nothing. You must disable xdist with
-p no:xdistor-n 0. breakpoint()in CI or non-TTY contexts hangs the process. Never commit it.PYTHONBREAKPOINT=0disables allbreakpoint()calls. Check this env var if your breakpoint is not hitting.debugpy.listenblocks only if you also callwait_for_client(). Without it, execution continues and your first breakpoint may fire before the client is attached.- Attach to PID fails on hardened kernels due to
ptrace_scope. Workaround requires root or launching underdebugpyfrom the start. - pdb only debugs the current thread. For multithreaded code, use
debugpyor setthreading.settrace()per thread. - asyncio:
awaitinside pdb requires Python 3.13+ orinteractmode on older versions. For 3.11/3.12, useasyncio.run_coroutine_threadsafeor!stmt-based awaits viaasyncio.ensure_future. scripts/run_tests.shstrips credentials and setsHOME=. If your bug depends on user config or real API keys, it will not reproduce under the wrapper. Debug with rawpytestfirst.- pdb does not follow forks. Each child needs its own
breakpoint()orset_trace(). For Hermes subagents, debug one process at a time.
What pairs with this
- systematic-debugging, a structured methodology for isolating bugs before you reach for a debugger.
- node-inspect-debugger, the equivalent skill for debugging Node.js processes inside Hermes Agent.