Hermes Agent Wiki 非公式・日本語wiki
この skill をそのまま使う: GitHub で原文を見る

英語原文・frontmatter 込みで、Hermes が読み込む実体そのままです(このページの本文は日本語版)。

Python Debugpy

目次

Python のデバッグです。pdb の REPL と、debugpy のリモート接続(DAP)を使います。

skill の情報

提供元 最初から入っています
パス skills/software-development/python-debugpy
バージョン 1.0.0
作者 Hermes Agent
ライセンス MIT
対応プラットフォーム linux, macos
タグ debugging, python, pdb, debugpy, breakpoints, dap, post-mortem
関連 skill systematic-debugging, node-inspect-debugger

参考: SKILL.md 全文

Python のデバッガ(pdb と debugpy)

概要

道具は 3 つあり、状況で使い分けます。

ツール 使いどころ
breakpoint() と pdb 手元で対話的に調べる、いちばん手軽なやり方です。ソースに breakpoint() を書いて普通に実行すると、その行で REPL が開きます。
python -m pdb 手元のスクリプトを、ソースを書き換えずに pdb の下で起動します。ちょっと覗きたいときに便利です。
debugpy リモート、画面なし、「すでに動いているプロセスに後から接続する」場合に使います。DAP でやり取りし、ターミナルからスクリプトで操作でき、長く動き続けるプロセス(gateway、デーモン、PTY の子プロセス)にも使えます。

まずは breakpoint() から。 いちばん手間がかからず、それで済むことが多いからです。

使いどころ

  • テストが失敗したが、traceback を見ても値がおかしい理由がわからない
  • 関数を 1 行ずつ進めながら、コレクションが書き換わる様子を見たい
  • 長く動き続けるプロセス(hermes gateway、tui_gateway)の挙動がおかしく、再起動もできない
  • 事後解析。本番同然のコードで例外が出たので、落ちた場所のローカル変数を調べたい
  • サブプロセスや子プロセス(Python の _SlashWorker、PTY のブリッジワーカー)そのものがバグの現場である

使わなくてよい場面: print()logging.debug で 1 分もかからず片づくこと、pytest -vv --tb=long --showlocals ですでにわかることです。

pdb 早見表

pdb のプロンプト((Pdb))で使えます。

コマンド はたらき
h / h cmd ヘルプ
n 次の行へ(関数の中には入らない)
s 関数の中に入る
r いまの関数から抜けるまで進める
c 実行を続ける
unt N N 行目まで進める
j N N 行目に飛ぶ(同じ関数の中だけ)
l / ll いまの行の周辺 / 関数全体のソースを表示
w いまどこにいるか(スタックトレース)
u / d スタックを 1 つ上 / 下へ移動
a いまの関数の引数を表示
p expr / pp expr 式を表示 / 整形して表示
display expr 停止するたびに式を自動で表示
b file:line ブレークポイントを置く
b func 関数に入ったところで止める
b file:line, cond 条件つきブレークポイント
cl N N 番のブレークポイントを消す
tbreak file:line 一度だけ効くブレークポイント
!stmt 任意の Python を実行する(代入も可)
interact いまのスコープのまま本物の Python REPL に入る(Ctrl+D で戻る)
q 終了

いちばん強力なのは interact です。何でも import でき、複雑なオブジェクトを調べられ、状態を書き換えるメソッドまで呼べます。ローカル変数は既定では読み取り専用なので、書き換えたいときは (Pdb) プロンプトから !x = 42 のようにします。

レシピ 1: 手元でブレークポイントを置く

いちばん簡単なやり方です。ファイルをこう書き換えます。

def compute(x, y):
    result = some_helper(x)
    breakpoint()           # <-- drops into pdb here
    return result + y

あとは普通に実行します。breakpoint() の行で止まり、ローカル変数に自由に触れます。

コミットする前に breakpoint() を消すのを忘れないでください。 git diff で確かめるか、コミット前に grep をかけます。

rg -n 'breakpoint\(\)' --type py

レシピ 2: スクリプトを pdb の下で起動する(ソースは触らない)

python -m pdb path/to/script.py arg1 arg2
# Lands at first line of script
(Pdb) b path/to/script.py:42
(Pdb) c

レシピ 3: pytest のテストをデバッグする

hermes のテストランナーも pytest も、これに対応しています。

# 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

ただし scripts/run_tests.sh は、run_tests_parallel.py を通してテストファイルごとに出力を取り込んだサブプロセスで走らせます(xdist は使っていません)。そのため、このラッパー越しでは対話的な pdb は動きません。--pdb を使いたいときは pytest を直接実行してください。

source .venv/bin/activate
python -m pytest tests/foo_test.py::test_bar --pdb

この方法だと環境を隔離する仕組みは効かなくなります。デバッグ中はそれでかまいませんが、push する前にラッパー越しで走らせ直して確かめてください。

レシピ 4: どんな例外でも事後解析する


try:
    run_the_thing()
except Exception:
    pdb.post_mortem(sys.exc_info()[2])

スクリプト全体を包むこともできます。

python -m pdb -c continue script.py
# When it crashes, pdb catches it and you're in the frame of the exception

REPL や jupyter では、全体にフックを仕掛けるやり方もあります。


def excepthook(etype, value, tb):
    import pdb; pdb.post_mortem(tb)
sys.excepthook = excepthook

レシピ 5: debugpy でリモートからデバッグする(動いているプロセスに接続)

長く動き続けるプロセス向けです。Hermes の gateway、tui_gateway、デーモン、すでに挙動がおかしくてきれいに再起動できないプロセスなどが対象になります。

下ごしらえ

source <hermes-agent-repo>/.venv/bin/activate
pip install debugpy

パターン A: ソースを書き換えて、起動時にデバッガの接続を待たせる

エントリポイントの先頭近く(またはデバッグしたい関数の中)に、次を足します。


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

プロセスを起動すると、wait_for_client() のところで止まったまま待ちます。

パターン B: ソースを触らず、-m debugpy で起動する

python -m debugpy --listen 127.0.0.1:5678 --wait-for-client your_script.py arg1

モジュールとして起動する場合も同じ要領です。

python -m debugpy --listen 127.0.0.1:5678 --wait-for-client -m your.module

パターン C: すでに動いているプロセスに接続する

PID がわかっていて、相手の環境に debugpy が入っていることが前提です。

python -m debugpy --listen 127.0.0.1:5678 --pid <pid>
# debugpy injects itself into the process. Then attach a client as below.

カーネルやセキュリティ設定によっては、ptrace を使った割り込みが止められています(/proc/sys/kernel/yama/ptrace_scope)。その場合はこうします。

echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope

ターミナルからクライアントをつなぐ

ターミナル側の DAP クライアントとしていちばん手軽なのは、VS Code の CLI か、小さなスクリプトです。Hermes の中からなら、現実的な選択肢は 2 つあります。

選択肢 1: debugpy 用の自作 CLI REPL — 公式の機能ではありませんが、ごく小さな DAP クライアントを書けます。

# /tmp/dap_client.py

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.

一度きりの自動化なら十分ですが、対話的に使うにはつらい方法です。

選択肢 2: VS Code / Cursor / Zed からつなぐ — 利用者がそれらを開いているなら、launch.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>" }
  ]
}

選択肢 3: DAP をやめて remote-pdb を使う — ターミナルで動くエージェントが本当に欲しいのは、たいていこちらです。

pip install remote-pdb

コードにはこう書きます。

from remote_pdb import set_trace
set_trace(host="127.0.0.1", port=4444)   # blocks until connection

そのうえで、ターミナルからつなぎます。

nc 127.0.0.1 4444
# You get a (Pdb) prompt exactly as if debugging locally.

debugpy の DAP が大げさすぎるとき、remote-pdb はエージェントにとっていちばん扱いやすい選択肢です。debugpy は、IDE との連携が本当に必要なときだけ使ってください。

Hermes 固有のプロセスをデバッグする

テスト

レシピ 3 を見てください。ラッパーはサブプロセスの出力を取り込んでしまうので、対話的な pdb を使うなら pytest を直接実行します。

run_agent.py と CLI — 一度きりの実行

いちばん簡単なのは、怪しい行の近くに breakpoint() を置いて hermes を普通に実行することです。止まった時点で、操作はターミナルに戻ってきます。

tui_gateway のサブプロセス(hermes --tui から起動される)

gateway は Node 製 TUI の子プロセスとして動きます。やり方は 2 つあります。

A. gateway のソースを書き換える:

# tui_gateway/server.py near the top of serve()

debugpy.listen(("127.0.0.1", 5678))
debugpy.wait_for_client()

hermes --tui を起動します。TUI は固まったように見えます(裏側が待っているためです)。クライアントをつないで continue すると、実行が再開します。

B. 目的のハンドラで 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

TUI から対応するスラッシュコマンドを実行し、別のターミナルで nc 127.0.0.1 4444 とつなぎます。

_SlashWorker のサブプロセス

やり方は同じです。ワーカーの exec の経路に remote-pdbset_trace() を置きます。このワーカーはスラッシュコマンドをまたいで生き続けるので、最初の 1 回は接続するまで止まりますが、以降のスラッシュコマンドは仕掛け直さないかぎりそのまま通ります。

gateway(gateway/run.py

長く動き続けます。ハンドラに remote-pdb を置くか、どのみち gateway を再起動するなら debugpy--wait-for-client を付けて使います。

つまずきやすいところ

  1. 並列実行や出力の取り込みを行うランナーの下では、pdb は何も言わずに効かなくなります。 プロンプトは表示されず、テストがただ固まります(pytest-xdist でも、scripts/run_tests.sh がファイルごとに出力を取り込むサブプロセスでも同じです)。対話的にデバッグするときは、1 ファイルに絞って pytest を直接実行してください。
  1. CI や端末のない場所では、breakpoint() はプロセスを固まらせます。 手元では安全ですが、絶対にコミットしないでください。念のため、コミット前の grep を仕掛けておきます。
  1. PYTHONBREAKPOINT=0 はすべての breakpoint() を無効にします。ブレークポイントで止まらないときは、環境変数を確かめてください。
echo $PYTHONBREAKPOINT
  1. debugpy.listen だけでは止まりません。wait_for_client() も呼ぶ必要があります。 呼ばないと実行はそのまま進み、クライアントがつながる前に最初のブレークポイントを通り過ぎることがあります。
  1. 設定を固めたカーネルでは、PID への接続が失敗します。 ptrace_scope=1(Ubuntu の既定)では、同じユーザーの子プロセスに対する ptrace しか許されません。逃げ道は echo 0 > /proc/sys/kernel/yama/ptrace_scope(root が必要)か、最初から debugpy の下で起動することです。
  1. スレッド。 pdb がデバッグできるのは、いまのスレッドだけです。マルチスレッドのコードでは debugpy(スレッドを扱える DAP)を使うか、スレッドごとに threading.settrace() を設定します。
  1. asyncio。 pdb はコルーチンの中でも動きますが、pdb の中で await するには Python 3.13 以降が必要で、それより古い版では interact モードから await します。3.11 や 3.12 では、asyncio.run_coroutine_threadsafe を使う小技か、asyncio.ensure_future!stmt で呼ぶ形で待ちます。
  1. scripts/run_tests.sh は認証情報を取り除き、HOME=<tmpdir> を設定します。 バグが利用者の設定や本物の API キーに依存している場合、このラッパーの下では再現しません。まず素の pytest で再現させ、そのあとラッパー越しで確かめ直してください。
  1. fork と multiprocessing。 pdb は fork の先を追いません。子プロセスにはそれぞれ breakpoint()set_trace() が要ります。Hermes のサブエージェントは、1 プロセスずつデバッグしてください。

確認リスト

  • [ ] pip install debugpy のあと、python -c "import debugpy; print(debugpy.__version__)" で入ったことを確かめる
  • [ ] リモートデバッグでは、ポートが実際に待ち受けているか確かめる: ss -tlnp | grep 5678
  • [ ] 最初のブレークポイントで本当に止まる(止まらないなら、PYTHONBREAKPOINT=0 になっている、並列実行や出力の取り込みを行うランナーの下にいる、接続前に実行が終わっている、のどれかです)
  • [ ] where または w で、想定どおりの呼び出し履歴が出る
  • [ ] 後片づけ。コミットするコードに breakpoint()set_trace() が残っていない
rg -n 'breakpoint\(\)|set_trace\(|debugpy\.listen' --type py

場面別のレシピ

「この dict にキーがないのはなぜ?」

# add above the KeyError site
breakpoint()
# then in pdb:
(Pdb) pp d
(Pdb) pp list(d.keys())
(Pdb) w                # how did we get here

「単体では通るのに、まとめて実行すると落ちるテスト」

scripts/run_tests.sh tests/the_test.py   # confirm it fails under the isolated runner first
# 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.

「非同期のハンドラがデッドロックする」

# Add at handler entry

ハンドラを動かします。nc 127.0.0.1 4444 でつなぎ、w で止まっているフレームを見て、!import asyncio; asyncio.all_tasks() でほかに何が残っているかを見ます。

「Ink の子プロセスやサブプロセスが落ちたときの事後解析」

PYTHONFAULTHANDLER=1 python -m pdb -c continue path/to/entrypoint.py
# On crash, pdb lands at the frame of the exception with full locals