オンコールをエージェントに。第3回 最初のエージェント:Strandsによるインシデントトリアージ
最初のエージェントが稼働開始。AgentCore Runtime上のStrandsトリアージエージェント、証拠優先のシステムプロンプト、2つのread-onlyツールとデプロイ用Terraformを解説する。

このシリーズで最初に動くエージェントは、Python約260行で構成される。boto3を呼び出す前にクロスアカウントIAMロールを引き受ける2つのread-onlyツールを持つStrandsのAgentと、モデルが実際には確認していないことについて自信ありげに語るのを止めさせる、それだけを仕事とするシステムプロンプトだ。AgentCore Gatewayも、supervisorも、マルチエージェントのハンドオフもまだ登場しない。それらは後の回に回す。この回のテーマは、デバッグを難しくする要素を何も足す前に、1つのエージェントに1つの仕事を正しくやらせ、AgentCore Runtimeにデプロイすることだ。
第1回は舞台設定だった(エンジニア50人規模のSaaS企業、週40回のページャー、read-onlyをIAMで強制するという譲れない制約)。そしてBedrock Agentsの従来型や自前構築のループではなく、AgentCoreとStrandsを選んだ。第2回は、そのエージェントたちが生きるアカウント境界を構築した。ops-readonlyとops-mutateのスポークロール、そしてBedrockのクロスリージョンルーティングをラップするエージェントごとのアプリケーション推論プロファイルだ。この回はその両方を直接使う。ここでは何もゼロから作らない。第2回がすでに構築したものを消費するだけだ。コンパニオンコードはgithub.com/flightlesstux/agents-on-callのagents/triage/agent.pyとterraform/10-agent-runtime/にあり、以下のスニペットはすべてそのファイルからの抜粋であり、投稿用に簡略化したものではない。
Strandsエージェントの構造
Strands Agentsはモデル駆動のPython SDKで、AWSが2025年5月にApache 2.0でオープンソース化した。社外に出る前は、Amazon Q Developer、AWS Glue、VPC Reachability Analyzerの内部で使われていた。売りは新しいオーケストレーション言語ではない。すでにツール呼び出しの方法を知っているモデルを薄くラップするループであり、エージェントの書き方を変えることなくBedrock、Anthropic直接呼び出し、Ollama、LiteLLM経由のプロバイダにつなげられる。1つのエージェントの全体像は5つのステップに収まる。
1. 設定はファイル内のリテラルではなく環境から来る。ランタイムが知る必要のあるものはすべて、以下のTerraformモジュールによって環境変数として設定される。そのためアカウント固有の値や秘密情報がソースにハードコードされることはない:
AWS_REGION = os.environ.get("AWS_REGION", "eu-west-1")
INFERENCE_PROFILE_ARN = os.environ["AGENT_INFERENCE_PROFILE_ARN"]
OPS_READONLY_ROLE_NAME = os.environ.get("OPS_READONLY_ROLE_NAME", "ops-readonly")
SPOKE_EXTERNAL_ID = os.environ["SPOKE_EXTERNAL_ID"]2. ツールはデコレータを付けた素のPython関数だ。Strandsは関数の型ヒントとdocstringを読み取ってモデルが実際に目にするツール仕様を組み立てる。つまりdocstringは人間向けのコメントではなく、インターフェース契約そのものだ:
@tool
def cloudwatch_read(
spoke_account_id: str,
namespace: str,
metric_name: str,
dimensions: dict[str, str],
lookback_minutes: int = 60,
stat: str = "Average",
period_seconds: int = 60,
) -> str:
"""Read a CloudWatch metric's recent datapoints from a spoke account.
...
"""3. モデルは生のモデルIDではなく、第2回の推論プロファイルを経由して接続される。temperatureは意図的に低く保つ。このエージェントは証拠から診断を提案するのであり、言い回しに創造的なバリエーションは必要ない。
model = BedrockModel(
model_id=INFERENCE_PROFILE_ARN,
region_name=AWS_REGION,
temperature=0.1,
max_tokens=2048,
)4. エージェント自体は1回のコンストラクタ呼び出しだ。モデル、システムプロンプト、ツールのリストを渡すだけ。作成すべきオーケストレーショングラフは存在しない。Strandsがモデル自身のツール使用出力からツール呼び出しループを推論するからだ:
agent = Agent(
model=model,
system_prompt=SYSTEM_PROMPT,
tools=[cloudwatch_read, logs_read],
name="incident-triage",
description=(
"First-pass responder for CloudWatch alarms: reads metrics and logs, proposes a "
"root cause candidate, defers all remediation to a human or the runbook agent."
),
)5. AgentCore Runtimeのエントリポイントがこれをラップする。BedrockAgentCoreAppはStrands自体ではなく、別パッケージのbedrock-agentcoreから来る。Strandsはエージェントフレームワークであり、AgentCoreはホスティングレイヤーであり、@app.entrypointデコレータがその両者の継ぎ目だ。
app = BedrockAgentCoreApp()
@app.entrypoint
def invoke(payload: dict) -> dict:
"""AgentCore Runtime entrypoint.
...
"""
prompt = payload.get("prompt")
if not prompt:
return {"error": "payload.prompt is required"}
result = agent(prompt)
return {"result": str(result)}
if __name__ == "__main__":
app.run()agent(prompt)を呼ぶことが、外から見たエージェントループのすべてだ。Strandsはプロンプトとツール仕様をBedrockに送り、最終回答かツール使用リクエストのどちらかを受け取り、要求されたツールを実行し、結果を返し、モデルがツールを求めなくなるまでこれを繰り返す。このループはこのプロジェクトが所有するコードではない。第1回が却下した自前構築の選択肢を取っていたら、手書きしなければならなかったはずのコードだ。
システムプロンプト設計:結論より先に証拠
自信ありげに聞こえるトリアージエージェントは、「まだわからない」と言うエージェントよりも危険だ。深夜3時に出力を読む人間は、内容と同じくらいトーンに基づいて行動するからだ。システムプロンプトはほぼ全体が、エージェントを賢くするためではなく、この失敗モードを防ぐために存在する:
1. 結論より先に証拠。root cause候補を述べる前にcloudwatch_readとlogs_readを
呼び出すこと。少なくとも1つのメトリクスと1つのログループに問い合わせる前に
形成された仮説は、診断ではなく単なる推測であり、診断として提示してはならない。
2. すべての主張についてメトリクス名とネームスペースを引用すること。「レイテンシが
上昇した」ではなく、「AWS/ApplicationELB TargetResponseTimeが直近15分で
120msから4.8sへ上昇した」と述べる。ツール呼び出しにアクセスできない読者が、
同じクエリを再実行することで述べたすべての数値を検証できるようにすること。
3. 不確実性を明示的に述べること。...
4. ツールが見ることのできない原因についての推測をしないこと。...
5. 空の結果も発見である。クエリウィンドウ内にデータポイントがないメトリクスや、
0行を返すログクエリは証拠であり(障害がサイレントであるか、このログループより
上流にあることを示す)、何かが出てくるまでより広いウィンドウで再試行する理由には
ならない。
6. 構造化されたサマリーで締めくくること。アラーム、確信度付きのroot cause候補、
実行した正確なメトリクスとログクエリ、そしてもう1回ツール呼び出しができるとしたら
次に何を確認するか。ルール2が最も効いている。「メトリクス名を引用する」はスタイル上の好みに聞こえるかもしれないが、これがあるからこそエージェントの出力は、人間が信頼するしかない主張から、同じCloudWatchクエリを貼り付けるだけで15秒で検証できる主張へと変わる。ルール5が存在するのは、リトライ予算を持つエージェントの自然な失敗が、何かが見つかるまで時間ウィンドウを広げ続けたりクエリを変え続けたりすることだからだ。その結果、無関係であっても「何か」が原因として報告されてしまう。空の結果はデータだ。それを釣り続けるための促しではなく、報告すべき手詰まりとして扱うことが、エージェントがノイズから診断をでっち上げるのを防いでいる。
2つのツール、素のPython、Gatewayはまだない
第1回のターゲットアーキテクチャは、すべてのツールをAgentCore Gatewayの背後に置く。MCP互換で、IAMスコープを一元化し、プラットフォーム全体の4つのツールに対して1つの許可リストを持つ。それは第4回の話だ。ここではエージェントが1体、ツールが2つだけなので、Gatewayはまだ何もルーティング対象がない間接層になってしまう。両方のツールとも、エージェントが直接呼び出す素の@toolデコレータ付きPython関数であり、両方ともAWSに触れる前に同じことをする。対象スポークアカウントでops-readonlyを引き受けるのだ。
def _assume_ops_readonly(spoke_account_id: str) -> boto3.Session:
sts = boto3.client("sts", region_name=AWS_REGION, config=_boto_config)
role_arn = f"arn:aws:iam::{spoke_account_id}:role/{OPS_READONLY_ROLE_NAME}"
creds = sts.assume_role(
RoleArn=role_arn,
RoleSessionName="triage-agent-read",
ExternalId=SPOKE_EXTERNAL_ID,
DurationSeconds=900,
)["Credentials"]
return boto3.Session(
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
region_name=AWS_REGION,
)15分だ。ops-readonlyの信頼ポリシーが許す1時間の上限ではない。15分後もまだ認証情報を必要としているツール呼び出しは、別の何かで詰まっている。そして有効期間の短い認証情報は、万が一プロセスの外に漏れた場合の影響範囲を小さくする。cloudwatch_readはget_metric_statisticsをラップし、データポイントを古い順のJSONとして返す。logs_readはCloudWatch Logs Insightsクエリを開始して完了をポーリングし、20回の試行にわたって最大18秒でキャップされ、詰まったクエリに対しては空の結果を返すのではなく例外を送出する。サイレントにタイムアウトして0件マッチと報告するログクエリは、まさに「エラーは見つからなかった」と同じように読めてしまう。これはこのツールに絶対に持たせてはならない、唯一の失敗モードだ。両方のツールともspoke_account_id引数を受け取る。つまりどのアカウントに問い合わせるかは、デプロイに焼き付けられた値ではなく、アラームイベントが伝える内容に基づいてモデルが決めるということだ。
AgentCore RuntimeがLambdaやECSに対して買えるもの
ここにあるものは厳密にはAgentCore Runtimeを必須としない。Strandsエージェントは普通のPythonであり、boto3クライアントとループさえあればLambda関数やECSタスクでも動く。2025年10月13日にGA(一般提供)となったAgentCore Runtimeが買えるのは、そうでなければ自前で構築するしかなかったインフラ上の3つのものだ:
- セッションごとに8時間の実行ウィンドウ。Lambdaの15分というハードな上限は、非常に短命なエージェントか、それより長く動くもの、リトライを伴うマルチツール診断ループはそうなり得る、のための呼び出しをまたぐ手組みの継続機構のどちらかを強制する。AgentCore Runtimeのセッションモデルにはその回避策が不要だ。
- 呼び出し間の完全なセッション分離。個別のLambda実行コンテキストやECSタスクからセッションごとのサンドボックスを手作りする必要がない。
- 秒単位課金、アイドルCPUには課金なし。vCPU時間あたり$0.0895、GB時間あたり$0.00945を秒単位で課金し、メモリの下限は128MB。特筆すべきは、エージェントがLLMの応答やツール呼び出しを待ってブロックされている時間には一切課金されないことだ。この時間は通常、セッションの経過時間の30〜70パーセントを占める。ECSタスクやプロビジョンドLambdaであれば、CPUが何をしていようとそのアイドル時間にも課金される。
トレードオフは脚注ではなく実際の制約だ。AgentCore RuntimeはARM64命令セットしかサポートしないため、コンパイル済み拡張を持つ依存関係はすべてARM64またはmanylinuxのwheelに解決できなければならず、そうでなければアップロード時にELFヘッダー検証が失敗してデプロイが落ちる。このエージェントの2つの依存関係、strands-agentsとbedrock-agentcoreは現時点でコンパイル済み拡張を出荷していないため、ここでは問題にならない。このシリーズの中でネイティブなデータベースドライバのようなものを必要とする最初のエージェントで、これが牙を剥くことになる。その時点での修正は、素のpip installではなくpip install --platform manylinux2014_aarch64 --only-binary=:all:でデプロイ成果物をビルドすることだ。
ランタイム用のTerraform
aws_bedrockagentcore_agent_runtimeはagent_runtime_artifactの下で互いに排他的な2つの成果物タイプをサポートする。ランタイムバージョンとエントリポイントを持つS3 zipのcode_configurationか、ECRイメージURIのcontainer_configurationだ。このモジュールはcode_configurationを使う。AgentCore自身のCLIも、新規エージェントのデフォルトをコンテナビルドではなく同じ直接コードデプロイ経路(内部的にはCodeZipと呼ばれる)にしており、依存関係がちょうど2つでインストールすべきシステムパッケージもない以上、Dockerfileはこのエージェントにはまだ不要な儀式だ。依存関係のコンパイルが必要になる、あるいはランタイムがPythonでなくなる将来のエージェントこそがcontainer_configurationへ切り替えるきっかけであり、どちらかの成果物タイプをデフォルトで好むという話ではない。
resource "aws_bedrockagentcore_agent_runtime" "triage" {
agent_runtime_name = "${replace(var.platform_name, "-", "_")}_triage"
description = "Incident-triage agent: reads CloudWatch metrics and Logs Insights across spokes, diagnoses, never mutates."
role_arn = aws_iam_role.triage_runtime.arn
agent_runtime_artifact {
code_configuration {
entry_point = ["agent.py"]
runtime = "PYTHON_3_12"
code {
s3 {
bucket = aws_s3_bucket.agent_artifacts.id
prefix = aws_s3_object.triage_agent_code.key
version_id = aws_s3_object.triage_agent_code.version_id
}
}
}
}
environment_variables = {
AWS_REGION = var.aws_region
AGENT_INFERENCE_PROFILE_ARN = var.agent_inference_profile_arn
OPS_READONLY_ROLE_NAME = var.ops_readonly_role_name
SPOKE_EXTERNAL_ID = var.spoke_external_id
}
network_configuration {
network_mode = "PUBLIC"
}
protocol_configuration {
server_protocol = "HTTP"
}
tags = merge(var.tags, {
Agent = "triage"
})
}VPCアタッチメントではなくnetwork_mode = "PUBLIC"を選んでいるのは意図的だ。このエージェントのアウトバウンド呼び出しはBedrockのモデル呼び出しとクロスアカウントのsts:AssumeRoleだけであり、どちらもIAM認証されたHTTPS経由でアクセスする公開AWSサービスエンドポイントだ。データベースや内部ロードバランサーのような、このエージェントが直接話すプライベートなネットワークリソースは存在しないため、VPCアタッチメントはNATとエンドポイントのコストを何の見返りもなく追加するだけになる。実行ロールの権限ポリシーは、このエージェントが実際に触れることを許されている3つのものを反映して3方向にスコープされている。推論プロファイルARNと、そこがルーティングしうる基盤モデルARNに対するbedrock:InvokeModel(両方必要なのは、クロスリージョン推論がプロファイルレベルと宛先モデルレベルの両方で認可を行うためであり、2つ目の許可を欠くと、たまたまそれを持たないリージョンにルーティングされた呼び出しだけが失敗する。第2回がモデルアクセスそのものについて指摘した、まさにあの断続的に見える失敗だ)。このエージェントが問い合わせを許されているすべてのスポークアカウントの正確なops-readonly ARNにスコープされたsts:AssumeRole(ワイルドカードではなくリストから構築される)。そして自身のコード成果物プレフィックスに対するs3:GetObjectのみで、それより広い範囲は一切ない。
最初の診断、アラームからサマリーまで
スポークアカウントのcheckoutサービスに対して、AWS/ApplicationELB TargetResponseTimeのCloudWatchアラームが発火する。supervisorエージェントはまだ存在しない(第5回)ため、この回では人間がアラームのコンテキストを直接エージェントに貼り付ける。返ってくるもの、要約すると:
> cloudwatch_read(spoke_account_id="222222222222",
namespace="AWS/ApplicationELB", metric_name="TargetResponseTime",
dimensions={"LoadBalancer": "app/prod-checkout/50dc6c495c0c9188"},
lookback_minutes=30)
< 30個のデータポイント、直近12分でp-averageが0.14秒から4.6秒へ上昇
> cloudwatch_read(spoke_account_id="222222222222",
namespace="AWS/ApplicationELB", metric_name="HTTPCode_Target_5XX_Count",
dimensions={"LoadBalancer": "app/prod-checkout/50dc6c495c0c9188"},
lookback_minutes=30)
< 5XXカウントは18分間ほぼゼロで横ばい、その後TargetResponseTimeと共に上昇
> logs_read(spoke_account_id="222222222222",
log_group_name="/aws/ecs/prod-checkout",
query_string='fields @timestamp, @message | filter @message like /timeout|pool/ | sort @timestamp desc',
lookback_minutes=15)
< 41行、支配的なメッセージ: "HikariPool-1 - Connection is not available,
request timed out after 30000ms"エージェントのサマリー:app/prod-checkoutのAWS/ApplicationELB TargetResponseTimeは12分間で140msから4.6秒へ上昇し、レイテンシ上昇が始まった約6分後からHTTPCode_Target_5XX_Countが上昇し始めている。同じウィンドウで/aws/ecs/prod-checkoutに対するCloudWatch Logs Insightsは、コネクションプールのタイムアウトに一致する41行を返す。最も可能性の高いroot causeは、サービスのデータベースコネクションプールが枯渇しており、リクエストが30秒のプールタイムアウトに達するまでキューイングされていることだ。これが完全な5XXになる前に、上昇したTargetResponseTimeとして現れている。確信度:中程度、高くはない。このエージェントにはデータベース自身のコネクション数やプールの設定上の最大値を読み取れるツールがないため、たとえば下流の依存先がいつもより長くコネクションを保持しているケースとの区別がつかず、プールが実際に満杯であることを確認できないからだ。次に推奨する確認事項:runbookエージェントが存在するようになったらそのデータベースコネクションメトリクス、あるいは人間がプールの現在のアクティブコネクション数を直接確認すること。
最後の段落は、投稿用に付け足したヘッジではなく、システムプロンプトのルールが意図通りに働いている結果だ。エージェントは実在する仮説に対する実在する証拠を持ち、その背後にある正確なメトリクスとクエリを引用し、2つのツールが実際に見ることのできる範囲のちょうど境界で立ち止まり、その先を推測で埋めるのではなくギャップとして名指ししている。
注視すべき失敗モード
このエージェントを実際のアラームに対して動かす前に知っておく価値のあることが3つある。第一に、ops-readonlyに対するAssumeRole失敗、アカウントIDの誤り、external IDの期限切れ、まだオンボーディングされていないスポークは、最終サマリーで「このアカウントを読み取れなかった」として表面化しなければならない。モデルが成功した1つのデータソースだけから推論して回避する、サイレントにスキップされたツール呼び出しになってはいけない。完全な診断と同じ確信度で提示される部分的な診断は、診断なしよりも悪い。第二に、18秒というLogs Insightsのタイムアウトは実際の上限だ。フィルタの広い高ボリュームなログループへのクエリは正当な理由でそれより長くかかることがあり、その修正はモデルによるより絞り込んだクエリ(より狭い時間ウィンドウ、より具体的なフィルタ)であるべきで、詰まったツール呼び出しを詰まったエージェント呼び出しに変えてしまう、より長いタイムアウトではない。第三に、低いtemperatureは言い回しのばらつきを減らすが、モデルが回答前に両方のツールを呼ぶことを保証はしない。システムプロンプトのルール1こそが実際の強制メカニズムであり、あえて近道を誘うように設計されたアラーム、アラーム名だけで原因が強く示唆されるアラーム、に対してテストし、エージェントが結論を出す前にそれでも問い合わせを行うことを確認する価値がある。
次に読む
- 第2回 基盤編:トークンより先にTerraform。このエージェントが直接消費する
ops-readonlyロールとアプリケーション推論プロファイルについて。 - 第4回 ツールとGateway:MCP、許可リスト、read-onlyがデフォルト。同じ2つのツールに加えてさらに2つが、Python関数を1つずつ配線する代わりにAgentCore Gatewayの背後に移り、IAMスコープが一元化される回。
- ercan.cloudのAutomating AWS CloudWatch Log Group Tagging with Python and Boto3。この回のツールが使っているのと同じ、boto3をCloudWatchに対して使うパターンを、エージェント側ではなく素のオートメーション側から扱ったもの。
上記のスニペットから省いたIAMポリシードキュメントも含む完全なagent.pyとterraform/10-agent-runtime/モジュール全体は、コンパニオンリポジトリgithub.com/flightlesstux/agents-on-callにある。この種のプラットフォームを大規模に運用する際のデータベースとインフラ側の話はercan.cloudのフィールドノートで、ハブはercanermis.comにある。
Ercan の他のサイト
同じ著者、別の領域のサイトが2つ。