オンコールをエージェントに。第5回 チーム編:Supervisorと3人のスペシャリスト
Supervisorがトリアージ、runbook、costの各エージェントにネットワーク越しに委譲し、AgentCore Memoryが所見を束ねる。単一エージェントで足りる場合も正直に扱う。

第4回が1体を残した場所に、今や4体のエージェントが存在する。Supervisor、runbookエージェント、costエージェントがincident-triageに加わり、1つのプロセス内のPython関数呼び出しではなく、AgentCore Runtime自身のInvokeAgentRuntime APIによって連携する。4体すべてが今も、分離された個別のRuntimeリソースとしてデプロイされるからであり、それはそもそも第3回がAgentCore Runtimeを選んだ理由である分離と同じものだ。もう1つのAWSリソースが4体を結びつける。actor IDで4体すべてに共有される単一のAgentCore Memoryインスタンスであり、トリアージが午前3時に書いた診断結果は、数秒後に引き継ぐrunbookエージェントにも、来月これと似た形のインシデントを見るどのエージェントにも、なお読める状態で残る。
第1回はAgentCoreとStrandsを選び、1つのSupervisorの背後に4体のエージェントという目標の形を描いた。第2回はアカウント境界と、以降のすべてのエージェントが再利用してきたops-readonly/ops-mutateの分離を構築した。第3回は最初のエージェントを、2つのツールを自身のプロセスに直接配線した状態で出荷した。第4回はそれらのツールに加え、2つ目のreadツールとプラットフォーム唯一の変更系ツールをAgentCore Gatewayの背後に移した。エージェントごとに手作業で配線する4つの何かではなく、1つのMCPクライアントが発見できる4つのLambdaだ。この回は、その投資が1つの呼び出し元を超えて報われ始める回だ。runbookエージェントとcostエージェントはどちらも同じGatewayに接続し、クロスアカウントのクレデンシャル処理を何からも導出し直すことなく4つのツールすべてを継承する。コンパニオンコードはgithub.com/flightlesstux/agents-on-callのagents/supervisor/、agents/runbook/、agents/cost_optimizer/、そしてこの回で追加されたterraform/10-agent-runtime/にある。以下のスニペットはすべてそれらのファイルからの抜粋であり、投稿用に簡略化したものではない。
エージェント1体では足りなくなるとき
第3回のトリアージエージェントは、1つのシステムプロンプト、2つのツール、1つの仕事を持っていた。それは第4回の4ツール体制でも問題なく持ちこたえた。4つすべてが依然としてその1つの仕事、つまり診断はするが修復は決してしない、に仕えていたからだ。2体目のエージェントを実際に強いる圧力はツール数ではなく、仕事の数だ。runbookエージェント(診断結果を提案されたSSM Automation実行に変える)とcostエージェント(1日1回、促されることなくCost Explorerを読み、rightsizingを提案する)はトリアージの仕事の変種ではない。スケジュールも異なる別の仕事であり、1つのシステムプロンプトに詰め込めば、同じコンテキストウィンドウの中に矛盾する指示が同居することになる。「あなたは変更系のアクションを決して取らない。取れるツールを持っていない」(第3回から変わらないトリアージ自身のルール)と「マッチしたrunbookチャンクが裏付けるならssm_execute呼び出しを提案する」(runbookエージェントの実際の仕事)は、1つのプロンプトがきれいに並べて述べられる文ではない。両方をやれと言われたエージェントは、一方の仕事の規律をもう一方の行動への積極性の側へ漂流させるか、前者の慎重さの下で後者のニュアンスを失う。作業をプロンプトのセクション単位ではなくプロセス単位で分割することが、2回あとの今もトリアージ自身の規律を読める状態に保っているものだ。そのシステムプロンプトは第3回から1行も変わっていない。
コンテキストの肥大は同じ圧力の、より静かなバージョンだ。Cost Explorerの癖とrunbookコーパスの検索挙動まで抱えたトリアージエージェントは、ほとんどの場合、その呼び出しでやりもしない仕事のためにシステムプロンプトのトークンとツールスキーマの重みを費やす。それぞれが自分の仕事に必要なものだけを持つ4体のエージェントは、組織上の判断であると同時にトークン効率の判断でもある。
ツールとしてのエージェント:4つの独立したRuntimeに合う形
Strands Agents 1.0はプロダクションリリースで4つの新しいマルチエージェントプリミティブとAgent-to-Agentプロトコルサポートを出荷しており、「ツールとしてのエージェント」にはSDK内に複数の実在する形がある。子のAgentインスタンスを親のtools=[...]リストに直接渡す方法も、ツールスペック付きで公開するデコレータで包む方法も、どちらもうまく動き、どちらも同じことを要求する。サブエージェントが呼び出し元と同じPythonプロセスに住んでいることだ。それはこのプラットフォームの形ではない。トリアージ、runbook、costはそれぞれ自身のaws_bedrockagentcore_agent_runtimeリソースとしてデプロイされ、独立にバージョン管理され、独立にIAMでスコープされ、それぞれが第3回がまさにそのためにAgentCore Runtimeを選んだセッション分離の中にある。インプロセスのショートカットを使うために3つのRuntimeを1つのプロセスに畳み込むことは、構文上の便利さのためにその分離を取り消すことになる。
| 形 | 仕組み | このプラットフォームに合うか |
|---|---|---|
tools=[agent_instance] | StrandsのAgentオブジェクトを親自身のtoolsリストに直接渡す | 合わない。サブエージェントが同じプロセスにいる必要があり、3つの独立したRuntimeの分離を1つに畳み込んでしまう |
.as_tool() | インプロセスのエージェント呼び出しを、ツールスペックを公開するデコレータで包む | 合わない。理由は同じで、依然として1プロセスであり、クラッシュしたときの爆発半径も1つになる |
@toolからInvokeAgentRuntimeを呼ぶ | デプロイ済みの別エージェントのRuntimeをネットワーク越しに呼ぶ、素のStrandsツール関数 | 合う。第3回のTerraformがすでにコミットした、4つの独立したRuntimeという形に一致する |
そこでSupervisorは3つ目の形を使う。各スペシャリストのランタイムARNに対してbedrock-agentcoreのInvokeAgentRuntimeデータプレーン操作を呼び出す、@toolデコレータ付きの関数だ。トレードオフは現実のものであり、隠すのではなく名指しする価値がある。インプロセス呼び出しは関数呼び出しだが、これは独自のレイテンシと途中で失敗する独自の様式を持つネットワークホップだ。偶然ではなく意図的に、第3回がすでにコミットした分離のために支払った対価だ:
def _invoke_specialist(agent_runtime_arn: str, actor_id: str, prompt: str) -> str:
"""Invoke one specialist's AgentCore Runtime and return its text result.
...
"""
response = _agentcore.invoke_agent_runtime(
agentRuntimeArn=agent_runtime_arn,
runtimeSessionId=_session_id,
contentType="application/json",
payload=json.dumps({"prompt": prompt, "session_id": _session_id}).encode("utf-8"),
)
if response["statusCode"] != 200:
raise RuntimeError(f"{actor_id} runtime invocation failed with status {response['statusCode']}")
body = json.loads(response["response"].read())
if "error" in body:
raise RuntimeError(f"{actor_id} returned an error: {body['error']}")
return body["result"]
@tool
def ask_triage_agent(alarm_context: str) -> str:
"""Ask the incident-triage specialist to diagnose a firing alarm.
...
"""
result = _invoke_specialist(TRIAGE_AGENT_RUNTIME_ARN, "incident-triage", alarm_context)
record_incident_event("supervisor", _session_id, {"delegated_to": "incident-triage", "result": result})
return result立ち止まる価値のある細部が2つある。第一に、_invoke_specialistは非200ステータスやエラーボディに対し、通常の所見のように読める文字列を返す代わりに例外を送出する。「runbookは何も見つけなかった」と「runbookの呼び出しが失敗した」を区別できないSupervisorは、人間に伝える内容に偽りの確信を合成してしまう。第二に、_session_idはスペシャリスト呼び出しごとではなくSupervisorの呼び出しごとに1回だけ発行され、すべてのスペシャリストに変更されないまま渡される。これがAgentCore Runtimeに3つのスペシャリストすべてのトレースを1つのインシデントの下にまとめさせるものであり、後述の共有メモリに、あるインシデントのイベントを別のインシデントのものと区別させるものだ。
runbookエージェント:SSMドキュメント上のKnowledge Base、提案はするが実行はしない
runbookエージェントの仕事のすべては、診断結果を、実在するドキュメントに由来する、でっち上げではない修復候補に変えることだ。そこに到達するためのツールソースは2つある。runbookコーパス上に構築されたBedrock Knowledge Baseに対する新しいkb_retrieveツールと、Gatewayがすでにインデックスしているすべてのツールだ。後者は手で宣言されるのではなく、接続時に発見される:
@tool
def kb_retrieve(query: str, max_results: int = 5) -> str:
"""Search the runbook Knowledge Base for playbook chunks relevant to query.
...
"""
response = _bedrock_agent_runtime.retrieve(
knowledgeBaseId=RUNBOOK_KNOWLEDGE_BASE_ID,
retrievalQuery={"text": query},
retrievalConfiguration={"vectorSearchConfiguration": {"numberOfResults": max_results}},
)
...
return json.dumps({"query": query, "chunks": chunks})
_gateway = connect_gateway_tools()
agent = Agent(
model=model,
system_prompt=SYSTEM_PROMPT,
tools=[kb_retrieve, *_gateway.list_tools_sync()],
name="runbook",
...
)connect_gateway_tools()はGatewayのMCPエンドポイントへのすべてのリクエストに、ランタイムコンテナ自身の実行ロールのクレデンシャルでSigV4署名を行う。Gatewayリソース自体にauthorizer_type = "AWS_IAM"を選んだ第4回の選択と対になっており、そのIAMロールが到達できるツール、すなわちcloudwatch-read、logs-read、cost-read、ssm-executeを返す。第4回が構築したのと同一の4つであり、このエージェント自身のコードのどこにも列挙も参照もされていない。モデルが実際にssm-executeを呼ぶかどうかはシステムプロンプトとIAMの判断であって、コード時点の判断ではない。そしてssm-executeは第4回から変わっていない。依然としてすべての呼び出しにdiagnosisとblast_radiusを要求し、依然としてStep Functionsの承認フローを開始するだけであり、スポークアカウントのアクションを直接実行することは決してない。runbookエージェントのシステムプロンプトはそれを暗黙のルールではなくハードなルールにしている。ssm_executeの呼び出しは、モデル自身の文章がどれほど自信ありげに聞こえようと、Supervisorには「完了」ではなく「人間に送付済み」として報告される。
costエージェント:同じゲートに流れ込む定期スイープ
costエージェントは、何にもページされない唯一のスペシャリストだ。EventBridge Schedulerがスポークごとに1日1回、どのアラームにも促されずに呼び出す。スケジュール自体はdocstringの中の主張として残されるのではなく、完全にTerraformで定義されている:
resource "aws_scheduler_schedule" "cost_agent_daily" {
name = "${var.platform_name}-cost-agent-daily"
description = "Daily trigger for the cost agent's Cost Explorer sweep across configured spoke accounts."
schedule_expression = "cron(0 6 * * ? *)"
flexible_time_window {
mode = "OFF"
}
target {
arn = "arn:aws:scheduler:::aws-sdk:bedrockagentcore:invokeAgentRuntime"
role_arn = aws_iam_role.cost_scheduler.arn
...
}
}読み取りはrunbookと同じGateway接続を通じて行う(cost-readに加え、支出の異常を実際の負荷と突き合わせてからフラグを立てるためのcloudwatch-readとlogs-read)。そしてrightsizingのアクションが妥当に見えるときは、runbookが使うのと同一のssm-execute経路を通じて提案する。1つの承認パイプラインに2つの提案者。Slackをレビューする人間は、保留中の提案がインシデント診断から来たのか夜間スイープから来たのかを知る必要も気にする必要もないはずだ。
意図的に設計へ組み込む価値がある唯一のことは、却下を蒸し返さないことだ。昨日の記憶を持たない日次スイープは、人間が承認するか却下に疲れるまで、同じ「アイドル」インスタンスに毎朝フラグを立て続ける。どちらの結末も良くない:
prior_episodes = retrieve_incident_context("cost", prompt)
if prior_episodes:
prompt = (
f"{prompt}\n\nNote: {len(prior_episodes)} related past cost proposal(s) "
"exist in memory; check whether any were denied before re-proposing."
)AgentCore Memory:1つの共有インシデント、4人の書き手
AgentCore Memoryの課金は、短期イベントがCreateEvent呼び出し1,000件あたり$0.25、長期検索がRetrieveMemoryRecords呼び出し1,000件あたり$0.50で、4体のエージェントが呼び出しごとに1イベントずつ書いても、モデル呼び出し1回のトークン価格の隣では丸め誤差にしかならないほど安い。ここで働いている戦略はAWSの組み込みタイプの1つであるEPISODICだ。各セッションを構造化されたエピソード(コンテキスト、推論、アクション、結果)として捕捉し、エピソード横断のリフレクションステップを自ら実行してより広いパターンを抽出する。このプラットフォームがその抽出をプロンプトエンジニアリングする必要はない。aws_bedrockagentcore_memory_strategyのEPISODICタイプへのTerraformサポートは2026年4月29日、この回自体の日付のほぼ2か月前に着地した:
resource "aws_bedrockagentcore_memory" "incident" {
name = "${replace(var.platform_name, "-", "_")}_incident_memory"
description = "Shared short-term event stream and long-term episodic memory for one incident's supervisor, triage, runbook, and cost agents."
event_expiry_duration = var.memory_event_expiry_days
# memory_execution_role_arn deliberately omitted: per the fact table, it
# is required only when a memory uses a CUSTOM strategy with a
# model-processing override block (SEMANTIC_OVERRIDE,
# USER_PREFERENCE_OVERRIDE, and so on). EPISODIC below is a built-in
# type, not CUSTOM, so AWS runs its own reflection step without this
# platform needing to grant or manage a role for it.
tags = merge(var.tags, { Component = "memory" })
}
...
resource "aws_bedrockagentcore_memory_strategy" "episodic" {
name = "incident-episodes"
memory_id = aws_bedrockagentcore_memory.incident.id
type = "EPISODIC"
namespaces = ["/incidents/{actorId}"]
}すべてのエージェントは同じ2つの関数を通じてその1つのメモリに書き込む。これが、独立にデプロイされた4つのRuntimeが共有状態の読み書きの仕方で漂流するのを防いでいるものだ:
def record_incident_event(actor_id: str, session_id: str, event: dict) -> str | None:
"""Write one short-term event to the shared incident memory.
...
"""
if not MEMORY_ID:
return None
response = _agentcore.create_event(
memoryId=MEMORY_ID,
actorId=actor_id,
sessionId=session_id,
eventTimestamp=time.time(),
payload=[
{
"conversational": {
"content": {"text": json.dumps(event, default=str)},
"role": "ASSISTANT",
}
}
],
)
return response["event"]["eventId"]この関数が意図的にやらないことが2つある。メモリIDが未設定のときは例外を送出する代わりにNoneを返す。メモリリソースがプロビジョニングされていないローカルテスト下のエージェントは、環境変数の欠落で診断を失敗させるのではなく「共有メモリなし」に格下げされて動き続ける。そしてactor_idはエージェントが渡す文字列であり、この関数が何かと照合して検証する値ではない。これも意図的だ。後で5体目のエージェントを追加してもここにスキーマ変更は不要で、同じ名前空間の形に書き込む新しいactorが1つ増えるだけだ。
正直に:マルチエージェントが過剰になるとき
4つのAgentCore Runtimeにメモリリソースとランタイム間ネットワーキングを加えたものは、より小さな運用チームなら7つのツールと長めのシステムプロンプトを持つ1体のエージェントとして回せるものに対する、実体のあるインフラだ。AgentCore Runtimeの課金はvCPU時間あたり$0.0895、GB時間あたり$0.00945でアイドル時間は無料なので、4つの小さなランタイムは絶対額としては高くない。だがそれはデプロイすべき4つのもの、監査すべき4つのIAMロールであり、単一プロセスには決して存在しないInvokeAgentRuntimeホップ1回分のレイテンシと故障面だ。分割の正直なトリガーは「このプラットフォームは成長した」ではなく、この回の冒頭と同じものだ。1つのシステムプロンプトの中で指示が互いに矛盾する別々の仕事、あるいは自然な単一ループを共有しない別々のスケジュール(アラーム駆動のエージェントと、促されない日次スイープ)だ。仕事が1つ、トリアージだけでそれ以外は何もない運用プラットフォームには、その圧力は一切かからず、良いツールを持つ1体のエージェントのままでいるべきだ。推論すべきランタイム間呼び出しもなく、陳腐化を防ぐべきメモリリソースもなく、1つで済むはずの3つのロールを調整するfor_eachのTerraformモジュールもない。2つ目の本物の仕事が存在する前に早々と分割することは、まだ誰も必要としていなかった分離を、まだ誰も望んでいなかったネットワークホップの対価で買うことだ。
注視すべき失敗モード
これが実際のインシデントに対して走る前に名指しする価値のあるものが4つある。トリアージはまだ共有メモリに書き込まない。その第3回のコードはこの回のメモリリソースより前のものであり、この回の中でそれをレトロフィットするものは何もない。つまりリフレクション戦略のエピソードには、ほぼすべてのインシデントで最初に走る唯一のエージェントが欠けている。取り繕うべき見落としではなく、実在するギャップだ。ランタイム間呼び出しが途中で失敗するのは、インプロセスの失敗とは別種の失敗だ。_invoke_specialistがインシデントの途中で例外を送出すると、Supervisorがすでにメモリに記録した内容が部分的なタイムラインとして残される。手作業でインシデントを引き継ぐ人間には有用だが、誰も確認しなければ完全なものと読み違えやすい。EPISODICのリフレクションステップはクローズされたセッションに対して非同期に走るため、retrieve_incident_contextが空のリストを返すことは「本当に新規」か「リフレクションがまだ走っていない」のどちらかを意味し、このコードもエージェントのプロンプトも現時点では両者を区別できない。そして新しいエージェントのIAMポリシーには実在する順序依存がある。Supervisorのロールポリシーはrunbookとcostのランタイム自身のARNを参照するため、それらのランタイムのプロビジョニングとSupervisorのロールのプロビジョニングの間で中断されたTerraform applyは、次のplanにSupervisorのポリシーを未適用として表示させる。恒久的な設定ミスではないが、部分的なapplyを信用するのではなくapplyを最後まで走らせるべき理由だ。
次に読む
- 第4回 ツールとGateway:MCP、許可リスト、read-onlyがデフォルト。この回のrunbookエージェントとcostエージェントが、何も導出し直すことなく接続して再利用する4つのツールについて。
- 第6回 Guardrails:誰もがスキップするパート。この4体すべてのモデル呼び出しが、まだどれも持っていないコンテンツとプロンプトインジェクションの層を得る回だ。
- ercan.cloudのHow to call multiple terraform modules in a single terragrunt file。この回の
for_eachベースのエージェントランタイムが1つのモジュールの内側で解いているのと同じ、「独立にデプロイされる複数のユニットを、間の配線を重複させずに調整する」問題を、マルチモジュールの側から扱ったもの。
完全なagents/supervisor/、agents/runbook/、agents/cost_optimizer/と、その背後にあるメモリおよびランタイムのTerraformは、コンパニオンリポジトリgithub.com/flightlesstux/agents-on-callにある。この種のプラットフォームを大規模に運用する際のデータベースとインフラ側の話はercan.cloudのフィールドノートで、ハブはercanermis.comにある。
Ercan の他のサイト
同じ著者、別の領域のサイトが2つ。