座学 ④ / 11:45 – 12:20 / このコマの中心
テンプレート実装の
アーキテクチャ読解
配布される z4_multiagent_templates を読む。 座学②③で扱った概念が、 実物のコードでどの行になっているかを突き合わせていく。
このテンプレートの設計目標はひとつ。各エージェントを1ファイルに閉じ込め、単独で開発・実行できるようにすること。 以下で見る仕掛けは、ほぼ全部これを成立させるためにある。
配布直後のテンプレートは汎用的なサンプル実装だが、このゼミではコマ2で agent01=偵察、agent02=侵入、agent03=権限昇格に差し替える。 このページでは、その差し替え前提で読む。
上から順に読むのは時間の無駄。まず2種類の検索をする。
# ① エージェントが何体いて、それぞれ何を返すか → 粒度が分かる
grep -En "^(agent = Agent\(|orchestrator)" *.py
grep -n "output_type=" *.py
# ② 誰がどのツールを持っているか → ツール数と検証の有無が分かる
grep -En "@agent\.tool|@agent\.output_validator" *.py
# PowerShell の例
Select-String -Path *.py -Pattern '^(agent = Agent\(|orchestrator)'
Select-String -Path *.py -Pattern 'output_type='
Select-String -Path *.py -Pattern '@agent\.tool|@agent\.output_validator'
答え合わせ:エージェントは4体(orchestrator + agent01/02/03)、 ツールは1体あたり3個。座学③で見た 「選択精度が保てる帯域」にきちんと収まっている。
1ファイル構成 — 依存の向きが設計そのもの
このテンプレートで最も重要な一行は、README に書かれたこれ。
「agentXX.py は他の agentYY.py を import しません。」
依存先は config.py だけで、エージェント同士の接続はすべて core.py が行う。
templates/index.html"] H["HTTP
curl / 外部サービス"] C["CLI
python core.py …"] end subgraph core["core.py — 接続を担当する唯一の場所"] F["Flask API
/api/chat · /api/pipeline
/api/agents/[name]"] O["orchestrator
委譲方式(LLM が判断)"] P["run_pipeline
決め打ち順序"] R["AGENT_MODULES
レジストリ"] end subgraph subs["サブエージェント — 1ファイル=1エージェント"] A1["agent01.py
偵察"] A2["agent02.py
侵入"] A3["agent03.py
権限昇格"] end CFG["config.py
Settings · build_model · AppDeps
AgentOutput · AgentCard · AgentModule
track_run · Artifact · RecordingAgent"] HIS["history.py
SQLite 永続化"] W --> F H --> F C --> core F --> O F --> P F --> R O -->|delegate_task| subs P -->|01 → 02 → 03| subs R -.->|単体実行| subs A1 -.-> CFG A2 -.-> CFG A3 -.-> CFG core -.-> CFG F -.-> HIS A1 -.-x A2 A2 -.-x A3 classDef e fill:#f6fbfc,stroke:#cddde2,color:#365360 classDef c fill:#f0faf6,stroke:#2ec4a0,color:#10262e classDef s fill:#fdf8f1,stroke:#f2b544,color:#10262e classDef g fill:#ffffff,stroke:#0e8f9e,color:#10262e class W,H,C e class F,O,P,R c class A1,A2,A3 s class CFG,HIS g
| 渡すもの | 経路 |
|---|---|
| テキスト・構造化データ | 呼び出し側(core.py)が次のエージェントのプロンプトに埋め込む。
JSON にして丸ごと差し込む(→ 3節) |
| ファイル(ログ・証拠ファイルなど) | deps.artifacts に登録し、後段が証拠確認ツールで拾う(→ 7節) |
この2つ以外の経路が生えていないかが、読むときの検査項目。 グローバル変数やモジュール間 import が1つでもあると、独立開発が崩れる。
2AgentModule プロトコル — agentXX.py を同じ形で扱う約束
core.py は、agent01.py の中身を詳しく読みに行かない。
見るのは NAME、CARD、agent、run() の4つだけ。
この4つが揃っていれば、core.py は「これは呼び出せるエージェントだ」と判断できる。
つまり AgentModule は「実装の中身」ではなく、他のファイルから見える入口の形を決める約束。 Python のクラス継承ではなく、必要な名前と関数が揃っているかを見るための型だと考えると分かりやすい。
NAME = "agent01"
CARD = AgentCard(
name=NAME,
title="偵察",
description=("ポート、サービス、HTTP エンドポイント、技術スタックを列挙し、"
"脆弱性候補を evidence 付きの findings にまとめる。"),
tags=["recon", "enumeration", "pentest"],
example_query="10.10.10.5 の攻撃面を調べて",
)
agent = Agent(
build_model(NAME), # AGENT01_MODEL_NAME で個別に差し替えられる
name=NAME,
deps_type=AppDeps, # ← 座学②の「依存の注入」
output_type=Agent01Output, # ← 座学②の「出力の型」
instructions=INSTRUCTIONS,
model_settings=build_model_settings(temperature=0.1),
retries=get_settings().max_retries,
capabilities=CAPABILITIES, # ネイティブ Web 検索(→ 8節)
)
async def run(query, deps, *, usage=None, usage_limits=None, message_history=None):
async with track_run(deps, NAME, query) as record: # ← 記録(→ 6節)
result = await agent.run(query, deps=deps, usage=usage,
usage_limits=usage_limits or build_usage_limits(),
message_history=message_history)
record.set_output(result.output)
return result.output
自分が担当するファイルは、この run() の形を絶対に崩さないこと。
usage を受け取らなくしたり、track_run を外したりすると、
委譲・予算集計・WebUI の表示が同時に壊れる。中身(プロンプト・ツール・出力型)はいくら変えてもよい。
32つの実行モード — 委譲と決め打ち
同じ3体のエージェントを、2通りの繋ぎ方で動かせる。 座学③で扱った「トポロジ」の実例が、そのまま両方入っている。
run_orchestrator()
LLM が呼ぶ相手と回数を決める
強み:依頼が毎回違っても対応できる。不要なエージェントは呼ばない。
弱み:実行内容が毎回変わる。評価しづらく、再現性が低い。
委譲ツールはdelegate_task(agent_name, task) 1つだけ。
宛先は引数で指定する。座学③の「ツール数を増やさない」工夫がここに効いている。
run_pipeline()
コードが順序を決める
強み:必ず 01 → 02 → 03。再現性があり、段ごとに型付きの中間結果が手に入るので評価しやすい。
弱み:不要な段も必ず通る。柔軟性がない。
前段の出力は model_dump_json(indent=2) でそのままプロンプトに埋め込まれる。
伝言ゲームの余地がない。
async def run_pipeline(query: str, deps: AppDeps) -> RunResult:
usage = RunUsage()
limits = build_usage_limits()
async with track_run(deps, "pipeline", query) as record:
recon = await agent01.run(query, deps, usage=usage, usage_limits=limits)
exploit_prompt = (
f"# 依頼\n{query}\n\n"
f"# 偵察結果 (agent01)\n{recon.model_dump_json(indent=2)}\n\n" # ← 丸ごと埋め込む
"上記の候補から、承認ゲートを通して侵入可否を検証してください。"
)
foothold = await agent02.run(exploit_prompt, deps, usage=usage, usage_limits=limits)
privesc_prompt = (
f"# 依頼\n{query}\n\n"
f"# 偵察結果 (agent01)\n{recon.model_dump_json(indent=2)}\n\n"
f"# 侵入結果 (agent02)\n{foothold.model_dump_json(indent=2)}\n\n"
"取得済みの足がかりを使って権限昇格を試み、証拠を残してください。"
)
privesc = await agent03.run(privesc_prompt, deps, usage=usage, usage_limits=limits)
output = PipelineOutput(query=query, recon=recon, foothold=foothold, privesc=privesc)
record.set_output(output)
return RunResult(output, usage) # usage は3体ぶんが合算されている
同じ usage オブジェクトを3体に渡し回しているのが分かるか。
これで座学③の「予算を実行ツリー全体で数える」が成立している。
片方だけ新しい RunUsage() を作ってしまうと、合計が過小に見えて上限が効かなくなる。
4委譲の実装 — SubAgents と RecordingAgent
座学③では「委譲ツールの中で別の Agent を run する」と説明したが、
このテンプレートは委譲ツールを手書きしていない。
pydantic-ai-harness の SubAgents に宣言するだけで生成される。
SUBAGENTS = [
SubAgent(
RecordingAgent(module.agent, module.NAME), # ← 入出力を記録するラッパー(後述)
name=module.NAME,
description=f"{module.CARD.title}: {module.CARD.description}", # ← CARD がここで効く
usage_limits=build_usage_limits(), # 委譲1回あたりの上限
timeout_seconds=get_settings().api_timeout, # 時間切れ
max_calls=2, # 同じ相手を呼べる回数 ← ループ抑制
on_failure=(f"{module.NAME} の委譲に失敗しました。"
"これは回復可能な事象として扱い、手元の情報で判断を続けてください。"),
)
for module in AGENT_MODULES.values() # ← レジストリから自動生成
]
orchestrator: Agent[AppDeps, OrchestratorOutput] = Agent(
build_model(ORCHESTRATOR_NAME),
...
capabilities=[
SubAgents(
agents=SUBAGENTS,
agent_folders=None, # ディスクからの自動読み込みは切る(登録場所を1つに集約)
forward_usage=True, # ← 親の usage を子へ。ツリー全体で上限判定
)
],
)
SubAgent 宣言の4つのつまみは、そのまま座学③の設計判断
| 宣言 | 座学③のどの話か | 変えたときに何が起きるか |
|---|---|---|
usage_limits | 予算配分 | 1回の委譲で使える上限。小さすぎると仕事が終わる前に切れ、大きすぎると1体が全部食う |
max_calls=2 | ループ検知 | 同じ相手を3回目に呼べなくなる。もっとも安いループ対策。増やすと同じ手の反復が増える |
timeout_seconds | 失敗回復 | 子が固まっても親が進める。無いと全体が止まる |
on_failure | 失敗回復(層3) | 失敗をモデルにどう伝えるか。「回復可能な事象として扱え」と書いてあるのがポイント — これが無いとモデルは失敗を致命的と解釈して止まる |
1回の委譲で何が起きているか
RecordingAgent がなぜ必要か
ここは実装の都合から生まれた仕掛けで、しかも読み飛ばすと後で必ず混乱する。
SubAgents は各サブエージェントの Agent.run() を直接呼ぶ。
つまり agent01.run()(=track_run で包んだ関数)を通らない。
pydantic_ai.Agent 本体"] G1 -.->|通らない| T1["agent01.run()
track_run(...)"] G1 --> R1["deps.runs
空のまま"] end subgraph good["RecordingAgent 経由 — 記録が残る"] O2["orchestrator"] -->|SubAgents| W2["RecordingAgent
WrapperAgent"] W2 -->|track_run で包む| G2["agent01.agent.run()"] W2 --> R2["deps.runs
入出力が残る"] end classDef b fill:#fff6f3,stroke:#f4674f,color:#10262e classDef g fill:#f0faf6,stroke:#2ec4a0,color:#10262e class O1,G1,T1,R1 b class O2,W2,G2,R2 g
RecordingAgent は WrapperAgent を継承し、run() を横取りして
track_run で包み直すだけのクラス。これが無いと、委譲方式のときだけ WebUI の「エージェント実行」が空になる。class RecordingAgent(WrapperAgent[Any, Any]):
def __init__(self, wrapped, name: str) -> None:
super().__init__(wrapped)
self._record_name = name
async def run(self, user_prompt=None, **kwargs):
deps = kwargs.get("deps")
if not isinstance(deps, AppDeps): # 記録先が無ければ素通し
return await self.wrapped.run(user_prompt, **kwargs)
async with track_run(deps, self._record_name, str(user_prompt or "")) as record:
result = await self.wrapped.run(user_prompt, **kwargs)
record.set_output(result.output)
return result
OrchestratorOutput.used_agents はLLM の自己申告ではない。
output.used_agents = [run.agent for run in deps.runs if run.agent in AGENT_MODULES]
実際の実行記録から機械的に埋めている。 「使った」と言っているのに記録が無い/記録があるのに申告していない、という食い違いが原理的に起きない。 座学①の「自己申告を信用しない」がコードで表現されている好例。
5AppDeps — 共有状態と依存注入
座学②で見た deps_type の実体。
全エージェント・全ツールが ctx.deps で触る唯一の共有物で、
ここに何が入っているかを把握すれば、エージェント間で何を共有できるかが分かる。
.env の全設定"] H["http: AsyncClient
共有 HTTP クライアント"] ID["session_id / user_id / locale
実行の文脈"] TR["trace: list[TraceStep]
何が起きたか"] RU["runs: list[AgentRunRecord]
何を渡して何が返ったか"] AR["artifacts: list[Artifact]
生成ファイル"] EX["extra: dict
逃げ道"] end A1["agent01
web_fetch"] -->|"http を借用"| H A1 -->|"record() で記録"| TR A2["agent02
run_python"] -->|"add_artifact"| AR A3["agent03
list_charts"] -->|"読む"| AR CORE["core.py"] -->|"作る・読む"| deps API["Flask レスポンス
runs · trace · artifacts"] -.->|そのまま JSON 化| deps HIS["history.py
SQLite"] -.->|保存| deps classDef d fill:#f0faf6,stroke:#2ec4a0,color:#10262e classDef a fill:#fdf8f1,stroke:#f2b544,color:#10262e classDef o fill:#f6fbfc,stroke:#0e8f9e,color:#10262e class S,H,ID,TR,RU,AR,EX d class A1,A2,A3 a class CORE,API,HIS o
AppDeps は「設定・接続・文脈・記録・生成物」の5役を兼ねる。
ツールが外界に触るときは必ずここを通るので、差し替えとテストが効く。セッションが混ざる
session_id ごとに生成物の置き場が変わる(artifact_dir())。
グローバルだと別セッションのグラフを掴む。
差し替えられない
ツールを確認するときは、LLM 全体を動かさずに port_scan(ctx, ...) のような関数だけを呼びたい。
そのために、テスト用の AppDeps に偽物の HTTP クライアントや一時ディレクトリを入れて渡す。
依存先がグローバル変数に固定されていると、テスト用の偽物に差し替えられず、本物の VPN・HTTP・ファイル置き場を使うしかなくなる。
記録が落ちる
deps.record() を通らない実行は trace にも runs にも残らない。
WebUI から見えない処理が生まれる。
config.py に「個別エージェントの知識」を持ち込まないための工夫。
エージェント固有の設定は Settings にフィールドを足さず、汎用ヘルパー経由で読む。
# config.py — 汎用の口だけを提供する(AGENT01_* を知らない)
def agent_flag(agent_key: str, name: str, *, default: bool = False) -> bool: ...
def agent_int(agent_key: str, name: str, *, default: int) -> int: ...
# agent01.py — 自分の設定は自分で読む(AGENT01_ENABLE_WEB_SEARCH)
ENABLE_WEB_SEARCH = agent_flag(NAME, "ENABLE_WEB_SEARCH", default=True)
WEB_FETCH_MAX_CHARS = agent_int(NAME, "WEB_FETCH_MAX_CHARS", default=4000)
# agent02.py
ENABLE_PYTHON = agent_flag(NAME, "ENABLE_PYTHON", default=True)
PYTHON_TIMEOUT = agent_int(NAME, "PYTHON_TIMEOUT", default=30)
もし Settings に agent01_enable_web_search を足していたら、
agent01 を消したときに config.py も直す必要が出る。
1ファイル削除で完結しなくなった時点で、独立性は失われている。
@agent.tool
async def web_fetch(ctx: RunContext[AppDeps], url: str, max_chars: int = WEB_FETCH_MAX_CHARS):
"""公開 Web ページを取得し、タグを除いた本文テキストを返す。"""
_validate_public_url(url) # SSRF 検査(→ 8節)
try:
response = await ctx.deps.http.get(url) # ← 共有クライアントを借りる
response.raise_for_status()
except Exception as exc:
ctx.deps.record(NAME, "web_fetch_failed", url=url, error=str(exc))
raise ModelRetry(f"ページの取得に失敗しました: {exc}") from exc
...
ctx.deps.record(NAME, "web_fetch", url=page.url, status=page.status, chars=len(page.text))
return page
失敗も成功も deps.record() しているのがポイント。
これで WebUI のツール呼び出し一覧に「試したが失敗した」が残る。
失敗の記録がないと、何もしなかったのか失敗したのかが区別できない。
6ログ設計 — trace と runs の2層
座学③で「ログはデバッグのためではなく切り分けのために設計する」と述べた。 このテンプレートは、その考え方を2つの別の型として実装している。 ここが読解のいちばんの収穫になるはず。
時系列の要約
class TraceStep(BaseModel):
agent: str
message: str # "start"/"done"/ツール名
at: str
data: dict # 引数など
軽い。ざっと流れを見るとき用。
deps.record() が1行追加する。
入出力の全文
class AgentRunRecord(BaseModel):
agent: str
input: str # プロンプト全文
output: dict | None
events: list[TraceStep] # ツール呼び出し
duration_ms: float | None
error: str | None
重い。だが「どの段で入力が壊れたか」はこれしか答えられない。
track_run() が管理する。
README にひとことで書かれている —
「マルチエージェントの不具合はほぼ全て『どの段で入力が壊れたか』です。」
trace だけでは「agent02 が呼ばれた」までしか分からない。
agent02 が受け取ったプロンプトに agent01 の findings が本当に入っていたかは runs[].input を見るしかない。
逆に runs だけだとツール呼び出しの前後関係が追いにくい。両方要る。
record() の地味だが効いている実装
def record(self, agent: str, message: str, **data) -> None:
step = TraceStep(agent=agent, message=message, data=data)
self.trace.append(step)
if message not in ("start", "done", "error"):
for run in reversed(self.runs): # ← 実行中の record を後ろから探す
if run.agent == agent and not run.finished:
run.events.append(step) # ← そこにツール呼び出しをぶら下げる
break
1回の record() で、時系列(trace)と実行単位(runs[].events)の両方に入る。
ツール呼び出しが「どのエージェントのどの実行で起きたか」に正しく紐づくのはこの5行のおかげ。
同じエージェントを2回呼んだときに、1回目と2回目のツール呼び出しが混ざらないのが要点。
失敗も記録に残す
# core.py の execute() — 例外が出ても履歴に書いてから再送出する
try:
result = runner.submit(coro_factory(deps, query), timeout=settings.api_timeout)
except Exception as exc:
if store is not None:
store.add(HistoryEntry(..., ok=False, error=f"{type(exc).__name__}: {exc}",
runs=deps.runs_dump(), trace=deps.trace_dump(), ...))
raise
失敗こそ後から見返したい。成功だけ保存する実装をよく見かけるが、
改善作業で読みたいログは失敗した実行のほう。
しかも失敗した時点までの runs が残るので、どこまで進んで落ちたかが分かる。
7Artifact — エージェント間のファイル受け渡し
テキストはプロンプトに埋め込めるが、画像は埋め込めない。 agent02 が作ったグラフを agent03 が本文に載せる、という連携をどう実装するか。 ここには「LLM に URL を捏造させない」ための仕掛けが入っている。
plt.savefig("hist.png") A2->>FS: 一時ディレクトリから回収してコピー A2->>D: deps.add_artifact(agent, path, caption) Note over D: url = /api/artifacts/セッションID/hist.png D-->>A2: Artifact(id / url / caption) A2->>A2: output.charts に url を入れる Note over A2: output_validator が
deps.artifacts に無い url を拒否 A3->>D: list_charts ツールで一覧取得 D-->>A3: AvailableChart[](url / caption / created_by) A3->>A3:  を markdown に埋め込む Note over A3: output_validator が
捏造 url と未埋め込みを拒否 UI->>FS: GET /api/artifacts/... で画像取得
deps.artifacts、外部への口は URL の3層。
agent02 と agent03 は互いを知らないまま、deps.artifacts を介してだけ繋がっている。# agent02.py — 自分が作っていないグラフを報告できない
@agent.output_validator
def _validate_charts(ctx: RunContext[AppDeps], output: Agent02Output) -> Agent02Output:
known = {a.url for a in ctx.deps.artifacts}
unknown = [c.url for c in output.charts if c.url not in known]
if unknown:
raise ModelRetry(f"存在しないグラフ URL があります: {unknown}. "
"run_python が返した charts の url をそのまま使ってください。")
return output
「グラフを作りました」という自己申告を、実体の存在で検証している。
deps.artifacts に無い URL は、そもそも出力として受け付けない。
同じ形が使える — 「〜を確認しました」と主張する出力に対して、
その根拠が deps のどこかに実在することを validator で確かめる。
このパターンは自分のエージェントにもそのまま移植できる。
8ツール層 — 4種類のツールを見分ける
@agent.tool が付いていれば全部同じ、ではない。
このテンプレートには実行場所の違う4種類が入っていて、
それぞれ失敗の仕方も、対策すべきことも違う。
④ ネイティブツール — 制御できないぶん、縮退のさせ方が重要
# agent01.py
CAPABILITIES = [NativeTool(WebSearchTool(optional=True))] if ENABLE_WEB_SEARCH else []
| 設定 | web_search | 挙動 |
|---|---|---|
MODEL_API=responses(既定) | 使える | プロバイダが検索を実行して結果を返す |
MODEL_API=chat | 非対応 | optional=True により自動で無効化され、web_fetch だけで動く(縮退) |
optional=True は「機能が使えなくても止まらない」を選んだということ。
逆に optional=False にすれば、非対応モデルでは UserError で落ちる。
どちらが正しいかはタスク次第。「検索できないなら意味がない」処理なら落ちるほうが安全で、
「あれば嬉しい」なら縮退が正しい。コメントにその理由が書かれているかを見る癖をつけるとよい。
③ run_python — 「サンドボックスではない」と明記してある
プロセス分離
別プロセス(python -I)で実行。アプリのメモリに触れない。
資源の上限
timeout / RLIMIT_AS / RLIMIT_CPU / RLIMIT_FSIZE(POSIX のみ)。
環境変数の遮断
OPENAI_API_KEY を渡さない専用 env を組み立てる。テストで漏れないことまで検証。
README にこう書いてある — 「⚠️ これはサンドボックスではありません。」
ネットワーク遮断はしていない。必ずコンテナ内で動かす。
信頼できない入力を扱うなら AGENT02_ENABLE_PYTHON=false。
安全策の「効き目」と「限界」が両方書かれているのは良いドキュメントの条件。
自分が機能を足すときも、限界のほうを書くこと。
② SSRF 検査 — ツールの引数はモデルが自由に決められる
def _validate_public_url(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"): raise ModelRetry(...)
if parsed.username or parsed.password: raise ModelRetry(...)
host = (parsed.hostname or "").lower()
if host in _BLOCKED_HOSTS or host.endswith(_BLOCKED_SUFFIXES): raise ModelRetry(...)
try:
address = ipaddress.ip_address(host)
except ValueError:
return url # ホスト名はそのまま通す
if address.is_private or address.is_loopback \
or address.is_link_local or address.is_reserved: raise ModelRetry(...)
return url
169.254.169.254(クラウドのメタデータ)・localhost・プライベート IP・.internal・
認証情報付き URL を拒否している。コメントに限界も書いてある —
「DNS 解決までは見ていない」。
ツールの引数は LLM が決めるという事実から出発すると、この種の検査が必要になる理由が分かる。
テストでは7パターンの拒否を検証している。
9暴走防止 — 3重の壁と、エラーの出口
座学③のループ検知・予算配分が、ここでは3つの独立した制限として実装されている。 どれか1つではなく、効く条件が違うものを重ねているのが要点。
エラーは HTTP ステータスに対応づけて外へ出す
座学②で「失敗を層に分ける」と述べた。
core.py はそれをFlask のエラーハンドラとして実装し、
症状を見ただけで原因の層が分かるようにしている。
| status | 例外 | 意味と、次に見る場所 |
|---|---|---|
| 400 | ValueError | query が空。リクエスト側の問題 |
| 404 | — | 存在しないエージェント名。AGENT_MODULES を見る |
| 429 | UsageLimitExceeded |
暴走。trace を見て委譲の連鎖を確認するのが定石 |
| 500 | AgentRunError | エージェント実行の失敗。runs[].error を見る |
| 502 | ModelHTTPError | LLM API 側のエラー。モデル設定(MODEL_API 等)を疑う |
| 504 | TimeoutError | API_TIMEOUT 超過。段数を減らすか延ばす |
429 が出たらプロンプトを直す前に trace を読む。502 が出たらプロンプトは無関係。
症状 → 見る場所の対応が付いていると、改善のイテレーションが一気に速くなる。
自分でエージェントを足すときも、新しい失敗モードには専用のステータスか専用の記録を用意する。
10agent04 を足す — 何が自動で追従するか
ここまで見た仕掛けの見返りがこの節。 新しいエージェントを足すのに触るファイルは2つだけで済む。
agent04.py を作る
NAME / CARD / INSTRUCTIONS /
出力スキーマ / ツール を書き換え"] S2["② core.py のレジストリに追加
AGENT_MODULES に 1 行"] S1 --> S2 S2 --> R1["orchestrator の委譲先"] S2 --> R2["ORCHESTRATOR_INSTRUCTIONS の
エージェント一覧"] S2 --> R3["POST /api/agents/agent04"] S2 --> R4["python core.py --agent agent04"] S2 --> R5["WebUI のタブ"] S3["③ tests/test_agent04.py を
コピーして作る"] S2 -.-> S3 classDef s fill:#f6fbfc,stroke:#0e8f9e,color:#10262e classDef r fill:#f0faf6,stroke:#2ec4a0,color:#10262e class S1,S2,S3 s class R1,R2,R3,R4,R5 r
SUBAGENTS がレジストリから生成され、SubAgents がプロンプトに一覧を差し込み、WebUI は /api/agents を読むため。run() を track_run で包み、record.set_output() を呼ぶこと。
これを忘れると、動くけれどWebUI の「エージェント実行」に何も出ないエージェントができる。
症状が「壊れている」ではなく「見えない」なので、原因に気づくのに時間がかかる。
agentXX.py だけを編集する
vim agent02.py # プロンプト / ツールを修正
python agent02.py "テスト入力" # 単体で動作確認(最速)
pytest tests/test_agent02.py -v # 回帰していないか
python core.py --pipeline "…" # 前段の出力を渡して確認
この4行が1サイクル。他のファイルを触らずに回せる。
疑う範囲を1段ずつ広げる
python agent02.py "…" # ① 単体で正しいか
python core.py --agent agent02 "…" # ② レジストリ経由でも同じか
python core.py --pipeline "…" # ③ 前段の出力を渡しても同じか
python core.py "…" # ④ 委譲でも同じか
①→④ で範囲が広がる。 どこで挙動が変わったかが、そのまま原因の場所。
11「なぜこの設計なのか」を議論する
ここからは解説ではなく、3人で議論しながら読むパート。 どれもトレードオフのある判断で、正解は用途によって変わる。 自分の答えを出してから開くこと。
ここで出た「自分ならこうする」はすぐには実装しない。
まず一度動かして、実際にどう失敗するかを見てからにする。
座学で予想した弱点が、実際にどの失敗として現れたか(あるいは現れなかったか)は、
成果報告書の考察章の良い材料になる — 予想が外れていた場合も含めて。
12当日の読解手順
35分で 4,200 行は読めない。この順で、この問いを持って読む。
| 順 | やること | 目安 | 持っていく問い |
|---|---|---|---|
| 1 | README_ja.md の「全体構成」と「責務の分け方」 | 4分 | どのファイルに何を置く/置かないか。この表がそのまま改修時のルールになる |
| 2 | config.py の AgentModule と AppDeps | 6分 | core.py が各 agentXX.py を呼ぶために、どの名前と関数を見ているか。→ 崩してはいけない入口が分かる |
| 3 | core.py の run_pipeline() | 5分 | 前段の出力がどう次のプロンプトに入るか。いちばん短くて分かりやすい実装 |
| 4 | core.py の SUBAGENTS と run_orchestrator() | 7分 | 委譲の4つのつまみ。used_agents をどこから埋めているか |
| 5 | 自分の担当エージェントを精読 | 8分 | INSTRUCTIONS / 出力スキーマ / ツール / output_validator。→ 改修範囲が確定する |
| 6 | config.py の track_run / record | 5分 | 失敗したとき、どこを見れば原因が分かるか |
全ファイルを読む
history.py・static/main.js・_harness.py は後回し。
必要になってから読めばよい。読まなかった場所は、問題が起きてから読む。
この場で改修を始める
まだ一度も動かしていない。動かす前の改善は、効いたかどうか分からない改善になる。 気づいた点はメモに書き留めるだけにする。
*コマ1の締め
座学が終わったら、4本の要点を突き合わせてコマ2で最初にどこへ手をつけるかを3人で決める。読解メモに書き留めた改善の候補が、そのまま着手順の候補になる。
決めること 1 — 何を作るか
このテンプレートは汎用の3体構成(リサーチ/分析/レポート)。 ゴールに向けて、どの責務のエージェントが必要かを洗い出す。
決めること 2 — 誰が何を持つか
1ファイル=1エージェント=1担当にすると、3人が同時に触ってもコンフリクトしない。
core.py と config.py は共同管轄。
決めること 3 — 何から手をつけるか
読解メモの改善候補に順番をつける。まず動かしてみて、実際の失敗と噛み合うものから。