Prismer execution contract
This is the uniquely named prismer-python-debugpy skill, adapted from Hermes.
Use the actual tools exposed by the executing host; examples using terminal,
process, delegate_task, vision_analyze or browser_* are not tool registrations.
Missing dependencies do not hide this skill. Report command startup, version,
account/permissions and task-specific live verification separately. Use task-owned
artifact paths and existing user authorization; do not change shared accounts,
Runtime/provider configuration, global security settings or unrelated work.
See NOTICE.md and LICENSE for resource provenance. Runtime availability and
upstream-entry suppression are owned by the integration layer.
Python Debugger (pdb + debugpy)
Overview
Three tools, picked by situation:
| Tool | When |
|---|---|
breakpoint() + pdb | Local, interactive, simplest. Add breakpoint() in the source, run normally, get a REPL at that line. |
python -m pdb | Launch an existing script under pdb with no source edits. Useful for quick poking. |
debugpy | Remote / headless / "attach to already-running process." Talks DAP, scriptable from terminal, works for long-lived processes (gateway, daemon, PTY children). |
Start with breakpoint(). It's the cheapest thing that works.
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
_SlashWorker, PTY bridge worker) is the actual bug site
Don't use for: things print() / logging.debug solve in under a minute, or things pytest -vv --tb=long --showlocals already reveals.
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:
pythondef 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:
bashrg -n 'breakpoint\(\)' --type py
Recipe 2: Launch a script under pdb (no source edits)
bashpython -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
Use raw pytest in a scoped test environment for an interactive debugger:
bash# Drop to pdb on failure (or on any raised exception): python -m pytest <exact-test-path> -x --pdb # Drop to pdb at the START of the test: python -m pytest tests/path/to/test_file.py::test_name --trace # Show locals in tracebacks without pdb: python -m pytest tests/path/to/test_file.py --showlocals --tb=long
The repository runner may capture output and cannot always support pdb. For an interactive session use a TTY and select one test, then rerun the actual canonical runner non-interactively for regression verification:
bashsource .venv/bin/activate python -m pytest tests/foo_test.py::test_bar --pdb
Keep the test environment isolated; do not import shared production credentials.
Recipe 4: Post-mortem on any exception
pythonimport pdb, sys try: run_the_thing() except Exception: pdb.post_mortem(sys.exc_info()[2])
Or wrap a whole script:
bashpython -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:
pythonimport 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
bashsource <hermes-agent-repo>/.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):
pythonimport 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
bashpython -m debugpy --listen 127.0.0.1:5678 --wait-for-client your_script.py arg1
Equivalent for module entry:
bashpython -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:
bashpython -m debugpy --listen 127.0.0.1:5678 --pid <pid> # debugpy injects itself into the process. Then attach a client as below.
If attach is blocked by OS policy, do not lower global ptrace protections. Start a new task-owned process under debugpy, or ask the system owner for an approved debugging environment.
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:
Use the bundled scripts/dap_client.py (Python stdlib); resolve its directory
from the installed skill, not cwd. With that directory on sys.path:
pythonimport socket from dap_client import DapClient with socket.create_connection(("127.0.0.1", 5678), timeout=5) as sock: client = DapClient(sock, timeout=5) client.request("initialize", {"adapterID": "python"}) attach_seq = client.send("attach", {}) client.wait(lambda p: p.get("event") == "initialized") # Configure the exact authorized source and line here. client.request("setBreakpoints", {"source": {"path": SOURCE}, "breakpoints": [{"line": LINE}]}) client.request("configurationDone") client.response(attach_seq) # Continue/step only the authorized target; disconnect when finished. client.request("disconnect", {"terminateDebuggee": False})
The transport handles EOF, timeout, size limits, events and request_seq matching. It is not a full IDE, and live debugpy handshake compatibility still needs a real session. Do not leave the target paused or enable remote/public listeners.
Option 2: Attach from VS Code / Cursor / Zed — if the user has one open, they can add a launch.json:
json{ "name": "Attach to Hermes", "type": "debugpy", "request": "attach", "connect": { "host": "127.0.0.1", "port": 5678 }, "justMyCode": false, "pathMappings": [ { "localRoot": "${workspaceFolder}", "remoteRoot": "<hermes-agent-repo>" } ] }
Option 3: Ditch DAP, use remote-pdb — usually what you actually want from a terminal agent:
bashpip install remote-pdb
In your code:
pythonfrom remote_pdb import set_trace set_trace(host="127.0.0.1", port=4444) # blocks until connection
Then from the terminal:
bashnc 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. The wrapper captures subprocess output, so run pytest directly for interactive pdb.
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:
python# 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:
pythonfrom 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 a parallel/output-capturing runner silently does nothing. You won't see the prompt, the test just hangs (true of pytest-xdist and of
scripts/run_tests.sh's captured per-file subprocesses). Run pytest directly on a single file for interactive debugging. -
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:bashecho $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. -
Do not change global ptrace policy; use a task-owned debugpy child.
-
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. -
Repository runners may isolate HOME/credentials or capture output. Inspect the actual runner before choosing an interactive invocation. Do not import real API keys into a debug session; use a scoped test credential only when authorized.
-
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 a parallel/capturing runner, or execution finished before attach) -
where/wshows the expected call stack - Post-debug cleanup: no stray
breakpoint()/set_trace()in committed codebashrg -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."
bashpython -m pytest tests/the_test.py # replace with the project's actual canonical runner # For interactive debugging, or if it only fails WITH other tests: source .venv/bin/activate python -m pytest tests/ -x --pdb # 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. 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."
bashPYTHONFAULTHANDLER=1 python -m pdb -c continue path/to/entrypoint.py # On crash, pdb lands at the frame of the exception with full locals


