Z4 / コマ1SEMINAR MATERIALS

座学 ④ / 11:45 – 12:20 / このコマの中心

テンプレート実装の
アーキテクチャ読解

配布される z4_multiagent_templates を読む。 座学②で扱った概念が、 実物のコードでどの行になっているかを突き合わせていく。

このテンプレートの設計目標はひとつ。各エージェントを1ファイルに閉じ込め、単独で開発・実行できるようにすること。 以下で見る仕掛けは、ほぼ全部これを成立させるためにある。

配布直後のテンプレートは汎用的なサンプル実装だが、このゼミではコマ2で agent01=偵察、agent02=侵入、agent03=権限昇格に差し替える。 このページでは、その差し替え前提で読む。

読解の第一歩 — 検索2回で構造の8割が見える

上から順に読むのは時間の無駄。まず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 が行う。

flowchart TB subgraph entry["入口(3通り)"] W["WebUI
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
図4-1 × が付いた線は「存在しない依存」 — agent01 → agent02 の直接呼び出しは無い。 サブエージェントは下向き(config.py)にだけ依存する。この向きが崩れていないことが、1体ずつ独立に開発できる根拠。
ファイルをクリックすると、置くもの・置かないもの・触るときの注意が出る クリックできる
エージェント間でデータを渡す口は2つだけ
渡すもの経路
テキスト・構造化データ 呼び出し側(core.py)が次のエージェントのプロンプトに埋め込む。 JSON にして丸ごと差し込む(→ 3節)
ファイル(ログ・証拠ファイルなど) deps.artifacts に登録し、後段が証拠確認ツールで拾う(→ 7節)

この2つ以外の経路が生えていないかが、読むときの検査項目。 グローバル変数やモジュール間 import が1つでもあると、独立開発が崩れる。

2AgentModule プロトコル — agentXX.py を同じ形で扱う約束

core.py は、agent01.py の中身を詳しく読みに行かない。 見るのは NAMECARDagentrun() の4つだけ。 この4つが揃っていれば、core.py は「これは呼び出せるエージェントだ」と判断できる。

つまり AgentModule は「実装の中身」ではなく、他のファイルから見える入口の形を決める約束。 Python のクラス継承ではなく、必要な名前と関数が揃っているかを見るための型だと考えると分かりやすい。

config.AgentModule (Protocol) NAME: str "agent01" — 識別子 レジストリのキー・URL の一部・ trace / runs のラベルになる CARD: AgentCard title / description / tags 委譲先の説明文になり、 WebUI のタブも自動生成される agent: Agent pydantic_ai.Agent 本体 プロンプト・ツール・出力型・ モデル設定が全部ここに入る run(...) async な呼び出し口 usage / usage_limits / message_history を受ける この4つだけで動く側 core.py 委譲・パイプライン・API・CLI tests/ 契約テスト isinstance(…) 単体実行 python core.py --agent name WebUI /api/agents からタブ生成 agent04.py を作ってレジストリに1行足すだけで、core.py・tests・単体実行・WebUI が同じ形で扱える
図4-2 agentXX.py は「中身を自由に変えてよい部分」と「外から見える入口」の2層に分かれる。 AgentModule が決めているのは後者だけ。逆に言えば この4つ以外のものに core.py が依存し始めた瞬間に、独立性は壊れる
agent01.py の頭 — 契約部分だけを抜き出すとこれだけ
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通りの繋ぎ方で動かせる。 座学③で扱った「トポロジ」の実例が、そのまま両方入っている。

同じ依頼を2つのモードで流す
0 / 0
CALL TREE
そのエージェントが受け取るプロンプト
(「次へ」を押してください)
委譲方式 — run_orchestrator()

LLM が呼ぶ相手と回数を決める

強み:依頼が毎回違っても対応できる。不要なエージェントは呼ばない。
弱み実行内容が毎回変わる。評価しづらく、再現性が低い。

委譲ツールはdelegate_task(agent_name, task) 1つだけ。 宛先は引数で指定する。座学③の「ツール数を増やさない」工夫がここに効いている。

決め打ち順序 — run_pipeline()

コードが順序を決める

強み:必ず 01 → 02 → 03再現性があり、段ごとに型付きの中間結果が手に入るので評価しやすい。
弱み:不要な段も必ず通る。柔軟性がない。

前段の出力は model_dump_json(indent=2)そのままプロンプトに埋め込まれる。 伝言ゲームの余地がない。

core.py — 決め打ち順序の実装(前段の出力の受け渡し方に注目)
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-harnessSubAgents に宣言するだけで生成される。

core.py — 委譲は「宣言」で済む。新エージェントの追加はリストに1行
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回の委譲で何が起きているか

delegate_task 一往復の分解 0 / 8
「1回の委譲」がコード上でどう展開されるかを追う。

RecordingAgent がなぜ必要か

ここは実装の都合から生まれた仕掛けで、しかも読み飛ばすと後で必ず混乱する。 SubAgents は各サブエージェントの Agent.run()直接呼ぶ。 つまり agent01.run()(=track_run で包んだ関数)を通らない

flowchart LR subgraph bad["ラッパー無し — 記録が落ちる"] O1["orchestrator"] -->|SubAgents| G1["agent01.agent.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
図4-4 RecordingAgentWrapperAgent を継承し、run() を横取りして track_run で包み直すだけのクラス。これが無いと、委譲方式のときだけ WebUI の「エージェント実行」が空になる。
config.py — 20行足らずのラッパー
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_agentsLLM の自己申告ではない

output.used_agents = [run.agent for run in deps.runs if run.agent in AGENT_MODULES]

実際の実行記録から機械的に埋めている。 「使った」と言っているのに記録が無い/記録があるのに申告していない、という食い違いが原理的に起きない。 座学①の「自己申告を信用しない」がコードで表現されている好例。

5AppDeps — 共有状態と依存注入

座学②で見た deps_type の実体。 全エージェント・全ツールが ctx.deps で触る唯一の共有物で、 ここに何が入っているかを把握すれば、エージェント間で何を共有できるかが分かる。

flowchart LR subgraph deps["AppDeps(dataclass)"] direction TB S["settings: Settings
.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
図4-5 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)
この判断の意味

もし Settingsagent01_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つの別の型として実装している。 ここが読解のいちばんの収穫になるはず。

同じ1回の実行を、2つの記録で見る
TraceStep

時系列の要約

class TraceStep(BaseModel):
    agent: str
    message: str       # "start"/"done"/ツール名
    at: str
    data: dict       # 引数など

軽い。ざっと流れを見るとき用。 deps.record() が1行追加する。

AgentRunRecord

入出力の全文

class AgentRunRecord(BaseModel):
    agent: str
    input: str        # プロンプト全文
    output: dict | None
    events: list[TraceStep]  # ツール呼び出し
    duration_ms: float | None
    error: str | None

重い。だが「どの段で入力が壊れたか」はこれしか答えられないtrack_run() が管理する。

なぜ2層に分けるのか

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 を捏造させない」ための仕掛けが入っている。

sequenceDiagram autonumber participant A2 as agent02 participant D as AppDeps.artifacts participant FS as data/artifacts ディレクトリ participant A3 as agent03 participant UI as WebUI / API A2->>A2: run_python で matplotlib
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: ![説明](url) を markdown に埋め込む Note over A3: output_validator が
捏造 url と未埋め込みを拒否 UI->>FS: GET /api/artifacts/... で画像取得
図4-7 ファイルの実体はディスク、参照は deps.artifacts、外部への口は URL の3層。 agent02 と agent03 は互いを知らないまま、deps.artifacts を介してだけ繋がっている。
両側の output_validator — URL の捏造を機械的に弾く
# 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種類が入っていて、 それぞれ失敗の仕方も、対策すべきことも違う。

このアプリのプロセス ① @agent.tool_plain 純粋な関数 lookup_severity / count_characters ctx を取らない。副作用なし ② @agent.tool deps を使う関数 web_fetch / list_charts / search_knowledge_base ③ @agent.tool → 別プロセス run_python(任意コード実行) python -I のサブプロセス/使い捨て一時ディレクトリ timeout・RLIMIT・環境変数を渡さない LLM プロバイダ側 ④ NativeTool web_search WebSearchTool(optional=True) 検索はプロバイダが実行する 外界 公開 Web(web_fetch が直接叩く) 内部ネットワークは _validate_public_url で遮断 ①②はコードで完全に制御できる。③は分離が必要。④は制御できない(対応しないモデルもある)
図4-8 どこで実行されるかで、必要な安全策が変わる。 ①②はテストで直接呼べる。③はプロセス分離。④は自分で書いていないので、対応可否だけ確認する。

④ ネイティブツール — 制御できないぶん、縮退のさせ方が重要

# 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 — 「サンドボックスではない」と明記してある

対策 1

プロセス分離

別プロセス(python -I)で実行。アプリのメモリに触れない。

対策 2

資源の上限

timeout / RLIMIT_AS / RLIMIT_CPU / RLIMIT_FSIZE(POSIX のみ)。

対策 3

環境変数の遮断

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つではなく、効く条件が違うものを重ねているのが要点。

WALL 1 — 回数 max_calls = 2 同じ相手を3回目に呼べない → 同じ手の反復を最も安く止める SubAgent 宣言(core.py) WALL 2 — 総量 UsageLimits requests 15 / tokens 200,000 → forward_usage でツリー全体の合計 .env(USAGE_LIMIT_*) WALL 3 — 時間 timeout_seconds 委譲1回 / HTTP 1本(API_TIMEOUT) → 固まった子を切り離して親は進む SubAgent 宣言 / Settings 回数だけ・総量だけでは止まらないケースがある。3つは互いの穴を埋め合っている 例:1回の委譲が巨大な入力を読み続ける → WALL 1 は無効、WALL 2 か 3 が止める
図4-9 「効く条件が違う制限を重ねる」が設計の型。 自分のエージェントに制限を足すときも、既存の壁がすり抜けるケースを考えてから足す。

エラーは HTTP ステータスに対応づけて外へ出す

座学②で「失敗を層に分ける」と述べた。 core.py はそれをFlask のエラーハンドラとして実装し、 症状を見ただけで原因の層が分かるようにしている。

status例外意味と、次に見る場所
400ValueErrorquery が空。リクエスト側の問題
404存在しないエージェント名。AGENT_MODULES を見る
429UsageLimitExceeded 暴走。trace を見て委譲の連鎖を確認するのが定石
500AgentRunErrorエージェント実行の失敗。runs[].error を見る
502ModelHTTPErrorLLM API 側のエラー。モデル設定(MODEL_API 等)を疑う
504TimeoutErrorAPI_TIMEOUT 超過。段数を減らすか延ばす
この対応表が、切り分けの地図になる

429 が出たらプロンプトを直す前に trace を読む。502 が出たらプロンプトは無関係。
症状 → 見る場所の対応が付いていると、改善のイテレーションが一気に速くなる。 自分でエージェントを足すときも、新しい失敗モードには専用のステータスか専用の記録を用意する。

10agent04 を足す — 何が自動で追従するか

ここまで見た仕掛けの見返りがこの節。 新しいエージェントを足すのに触るファイルは2つだけで済む。

flowchart LR S1["① agent01.py をコピーして
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
図4-11 右側の5つは全部自動で追従するSUBAGENTS がレジストリから生成され、SubAgents がプロンプトに一覧を差し込み、WebUI は /api/agents を読むため。
自動追従の裏返し

run()track_run で包み、record.set_output() を呼ぶこと。 これを忘れると、動くけれどWebUI の「エージェント実行」に何も出ないエージェントができる。 症状が「壊れている」ではなく「見えない」なので、原因に気づくのに時間がかかる。

改修の1サイクル(既存エージェント)

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 行は読めない。この順で、この問いを持って読む。

やること目安持っていく問い
1README_ja.md の「全体構成」と「責務の分け方」4分 どのファイルに何を置く/置かないか。この表がそのまま改修時のルールになる
2config.pyAgentModuleAppDeps6分 core.py が各 agentXX.py を呼ぶために、どの名前と関数を見ているか。→ 崩してはいけない入口が分かる
3core.pyrun_pipeline()5分 前段の出力がどう次のプロンプトに入るか。いちばん短くて分かりやすい実装
4core.pySUBAGENTSrun_orchestrator()7分 委譲の4つのつまみ。used_agents をどこから埋めているか
5自分の担当エージェントを精読8分 INSTRUCTIONS / 出力スキーマ / ツール / output_validator→ 改修範囲が確定する
6config.pytrack_run / record5分 失敗したとき、どこを見れば原因が分かるか
やらないこと

全ファイルを読む

history.pystatic/main.js_harness.py後回し。 必要になってから読めばよい。読まなかった場所は、問題が起きてから読む。

やらないこと

この場で改修を始める

まだ一度も動かしていない。動かす前の改善は、効いたかどうか分からない改善になる。 気づいた点はメモに書き留めるだけにする。


コマ1の締め

座学が終わったら、4本の要点を突き合わせてコマ2で最初にどこへ手をつけるかを3人で決める。読解メモに書き留めた改善の候補が、そのまま着手順の候補になる。

決めること 1 — 何を作るか

このテンプレートは汎用の3体構成(リサーチ/分析/レポート)。 ゴールに向けて、どの責務のエージェントが必要かを洗い出す。

決めること 2 — 誰が何を持つか

1ファイル=1エージェント=1担当にすると、3人が同時に触ってもコンフリクトしない。 core.pyconfig.py は共同管轄。

決めること 3 — 何から手をつけるか

読解メモの改善候補に順番をつける。まず動かしてみて、実際の失敗と噛み合うものから。

4本の座学が、このコードのどこに現れていたか
座学実装での現れ方
① 証拠と再現性 output_validator による URL 捏造の拒否/used_agents を実行記録から埋める/失敗も履歴に残す
② ループとツール Agent@agent.toolModelRetrydeps_typeoutput_type/生出力はファイル、要点だけを返す
③ 分割と予算 SubAgentsmax_calls / usage_limits / timeout_secondsforward_usage でツリー全体の合計
④ 読解 上の3つが、実際にどの行なのかを指せるようになった状態がゴール