[アップデート] AWS Step FunctionsのワークフローをAgentCore Gateway経由でAIエージェントのツールとして使えるようになりました!

[アップデート] AWS Step FunctionsのワークフローをAgentCore Gateway経由でAIエージェントのツールとして使えるようになりました!

AgentCore GatewayのターゲットにStep Functionsが対応しました!!
2026.10.30

はじめに

こんにちは、腰痛がひどいコンサル部の神野です。

AWS Step Functionsのワークフローを、Amazon Bedrock AgentCore Gateway経由でAIエージェントのツールとして使えるようになりました!

https://aws.amazon.com/jp/about-aws/whats-new/2026/10/step-functions-workflows-ai-agent-tools/

AgentCore Gatewayの連携がガンガン増えていきますね!本記事では変更点をまとめて実際に触ってみます!!

また、同じアップデートをふくちさん(@har1101mony)が詳しく検証されています!10個の連携できる操作の整理やタスクトークンを使ったエージェント同士の連携、日本語まわりのハマりどころなどが書かれているのでぜひご参照ください!

https://qiita.com/har1101/items/de6104e8057452ebd720

本記事ではそちらと重なる部分は軽めにして、動かしてみた手触りやCedar周りなどふかぼっていければと思います!

主な変更点

ワークフローがそのままツールになる

AgentCore GatewayにStep Functionsのコネクタが追加されました!ターゲットを作るときに連携する操作とステートマシンを選ぶとGatewayがツールの定義を作ります。エージェントからの呼び出しもGatewayがStep FunctionsのAPIへつないでくれます。

上段のこれまではエージェントが自作のツールからSDKでStep FunctionsのAPIを呼び出し、ツール定義と呼び出し処理を書いてエージェントに権限を持たせていた。下段の今回はエージェントがMCPでAgentCore Gatewayを呼び出し、Gatewayがサービスロールでステートマシンを実行する。ツール定義はGatewayが作り、ステートマシンもGateway側で固定する

これまでもSDKでStep FunctionsのAPIを直接呼び出せましたが、Gatewayを挟むとツール定義・権限・動かせるステートマシンをGateway側でまとめて管理できてシームレスに連携できるのが嬉しいですね!

Gatewayに集約すると何が嬉しいのかは下記記事でDeepDiveしているので必要に応じてみていただけると!

https://dev.classmethod.jp/articles/devio2026-osaka-agentcore-gateway-introduction/

エージェント側は「いつワークフローを動かすか」と「何を入力するか」を自律的に判断して、処理の実行順序や業務ルールなど確実に守らせたいものをステートマシン側で制御する棲み分けで活用するイメージですかね!

連携できる操作は10個

コネクタで連携できるStep Functionsの操作は10個です。ステートマシンを対象にする StartExecution や StartSyncExecution などは、ターゲットの設定でステートマシンのARNを固定します。固定したARNはツールの引数に出てこないので、エージェントが別のステートマシンを動かすことはできません。

連携できる操作の一覧は公式ドキュメントにまとまっています。

https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-connector-step-functions.html

StandardとExpressで使える操作が違う

一部の操作はワークフローの種類によって使えないので軽く押さえておきましょう。

操作 使える種類
StartSyncExecution Expressのみ(同期で結果まで待機)
ListExecutions・DescribeExecution・GetExecutionHistory・StopExecution Standardのみ
StartExecution・DescribeStateMachine 両方
SendTaskSuccess・SendTaskFailure・SendTaskHeartbeat Standardのみ(Expressはコールバックの待機に対応していない)

料金はStep FunctionsとAgentCore Gatewayの通常料金が適用されます。
Gatewayが使えるすべてのリージョンで利用できます!

もちろんコンソールからも作れる

Gatewayのターゲット作成画面でコネクターの「Step Functions」を選ぶとステートマシンと公開する操作を選べます。

Gatewayのターゲット作成画面。コネクターのStep Functionsを選び、固定するステートマシンと公開する操作を選ぶ

コンソールから手動で作ることもできますが、今回はCDKで作ります!!

やってみた

構成

私が大好きなスーパーマーケットを題材にします。

発注業務をイメージしてみます。
発注担当のエージェントが在庫を確認して発注し、合計が3万円を超える場合は店長がメールで承認する流れです。

発注担当エージェントはGatewayのorderとstockのターゲットを通して、発注ワークフロー(Standard)と在庫照会ワークフロー(Express)を呼び出す。在庫照会ワークフローはDynamoDBの在庫テーブルをGetItemで読む。発注ワークフローは3万円を超えるとSNSで店長にメールを送って止まり、店長がメールのリンクから承認すると承認用LambdaがSendTaskSuccessで再開させる

Gatewayには役割ごとに2つのターゲットを作りました。

ターゲット 操作 接続先
order 発注の開始と状況確認 発注ワークフロー(Standard)
stock 在庫の照会 在庫照会ワークフロー(Express)

ここで作る発注ワークフローは発注数と単価から合計を計算して分岐します。ざっとこんなような特徴です。

  • 3万円以下ならそのまま発注を確定し、超える場合はSNSで店長に承認依頼のメールを送って待ちます。
  • 店長がメールのリンクから承認すると承認用のLambdaが SendTaskSuccess でワークフローを再開させます。
  • 在庫照会ワークフローはDynamoDBの在庫テーブルから、商品名をキーに在庫数・単価・1日の販売数を取得して返します。
  • GatewayにはPolicy Engine(Cedar)も付けて、発注の取り消しを店長のロールだけに許可します。

発注ワークフローの流れはこんな感じです。

発注ワークフローの状態遷移。CheckOrderで合計を判定し、3万円以下はPlaceOrderで発注を確定する。3万円を超えるとWaitForApprovalでSNSのメールを送って店長の承認を待つ。IsApprovedで承認ならPlaceOrderへ進み、却下ならRejectedで失敗する。発注数が0以下ならInvalidOrderで失敗する

あくまで今回のアップデートを試すサンプルで実際に使えるものではないのでご承知おきください!

AWSのリソースはCDKで作り、エージェントはStrands Agentsで作ります。サンプルコードはGitHubにアップロードしています!

https://github.com/yuu551/agentcore-gateway-stepfunctions-tools

前提

今回使用した環境は下記です。

項目 内容
リージョン us-east-1(バージニア北部)
Node.js / pnpm 26.13.0 / 12.16.1
aws-cdk-lib / aws-cdk 2.275.0 / 2.1146.1
Python / uv 3.15.2 / 0.13.6
承認用Lambdaのランタイム Python 3.14
strands-agents 1.62.0
mcp-proxy-for-aws 1.7.0
モデル Claude Haiku 5.5(us.anthropic.claude-haiku-5-5)

なお、2026年10月30日時点ではGateway経由で渡した入力の日本語が「?」に化けます。今回のエージェントでは、ワークフローに渡すJSONの日本語を \uXXXX の形にエスケープしてから送っています。

CDKでリソースを作る

サンプルコードをcloneして、CDKの依存関係を入れます。

セットアップ
git clone https://github.com/yuu551/agentcore-gateway-stepfunctions-tools.git
cd agentcore-gateway-stepfunctions-tools/cdk
pnpm install

GatewayはL2の Gateway で書けますが、Step FunctionsのコネクタのターゲットはL2にまだないのでL1の CfnGatewayTarget で書きます。CloudFormationの AWS::BedrockAgentCore::GatewayTarget は Connector に対応しているので問題なくIaCで管理できます!

lib/stack.ts(ターゲットの部分)
// Step Functionsのコネクタターゲット(L2がないのでL1で書く)
const connectorTarget = (name: string, configurations: agentcore.CfnGatewayTarget.ConnectorConfigurationProperty[]) =>
  new agentcore.CfnGatewayTarget(this, `${name}Target`, {
    gatewayIdentifier: gateway.gatewayId,
    name,
    targetConfiguration: {
      mcp: { connector: { source: { connectorId: "states" }, configurations } },
    },
    credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }],
  });

connectorTarget("order", [
  { name: "StartExecution", parameterValues: { stateMachineArn: order.stateMachineArn } },
  { name: "ListExecutions", parameterValues: { stateMachineArn: order.stateMachineArn } },
  { name: "DescribeStateMachine", parameterValues: { stateMachineArn: order.stateMachineArn } },
  { name: "DescribeExecution" },
  { name: "GetExecutionHistory" },
  { name: "StopExecution" },
]);
connectorTarget("stock", [
  { name: "StartSyncExecution", parameterValues: { stateMachineArn: stock.stateMachineArn } },
]);

コネクタIDに states を指定し、configurations に連携する操作を記載します。
ステートマシンを固定する操作には parameterValues.stateMachineArn を指定します。

在庫照会ワークフローは dynamodb:getItem でDynamoDBの在庫テーブルを直接読みにいく作りとしています。

sfn/stock-lookup.asl.json(在庫を読む部分)
"GetStock": {
  "Type": "Task",
  "Resource": "arn:aws:states:::dynamodb:getItem",
  "Arguments": {
    "TableName": "${TABLE_NAME}",
    "Key": {
      "item": {
        "S": "{% $states.input.item %}"
      }
    }
  },
  "Output": "{% $exists($states.result.Item) ? {'item': $states.result.Item.item.S, 'stock': $number($states.result.Item.stock.N), 'unitPrice': $number($states.result.Item.unitPrice.N), 'dailySales': $number($states.result.Item.dailySales.N)} : {'item': $states.input.item, 'status': 'NOT_FOUND'} %}",
  "End": true
}

在庫テーブルには、デプロイ時に AwsCustomResource で牛乳・卵・米5kgの3件を入れています。

承認依頼のメールは、承認待ちのタスクで sns:publish.waitForTaskToken を使って送り、本文に承認用LambdaのURLとタスクトークンを入れています。

sfn/purchase-order.asl.json(承認待ちの部分)
"WaitForApproval": {
  "Type": "Task",
  "Resource": "arn:aws:states:::sns:publish.waitForTaskToken",
  "TimeoutSeconds": 86400,
  "Arguments": {
    "TopicArn": "${TOPIC_ARN}",
    "Subject": "Purchase order approval request",
    "Message": "{% '発注の承認をお願いします。\n\n商品: ' & $states.input.item & '\n数量: ' & $states.input.quantity & '\n合計: ' & $states.input.quantity * $states.input.unitPrice & '円\n理由: ' & $states.input.reason & '\n\n承認・却下はこちらから\n' & '${APPROVAL_URL}?token=' & $encodeUrlComponent($states.context.Task.Token) %}"
  },
  "Output": "{% $merge([$states.input, {'approval': $states.result}]) %}",
  "Next": "IsApproved"
}

承認待ちには TimeoutSeconds で24時間の期限を付けて、古いリンクでは承認できないようにしています。

承認用のLambdaはリンクを開くと承認・却下のボタンを表示し、ボタンが押されたら SendTaskSuccess で結果を返します。
流石にノーガードは嫌だなと思い簡易的ではありますが承認ページにはBasic認証を付けています。パスワードはCDKでSecrets Managerに生成し、Lambdaでチェックするような処理です。実際に使う際はしっかりと作り込みましょうね!

lambda/approval.py
import base64
import hmac
import html
import json
import os
from urllib.parse import parse_qs

import boto3

sfn = boto3.client("stepfunctions")
PASSWORD = boto3.client("secretsmanager").get_secret_value(SecretId=os.environ["PASSWORD_SECRET_ARN"])["SecretString"]

PAGE = """<!doctype html><meta charset="utf-8"><title>発注の承認</title>
<p>今回の検証用の簡素なページです。本番用ではありません。</p>
<form method="post"><input type="hidden" name="token" value="{token}">
<button name="action" value="approve">承認する</button>
<button name="action" value="reject">却下する</button></form>"""

def handler(event, context):
    # 店長のパスワードを知っている人だけが開けるようにする(Basic認証)
    if not authorized(event["headers"].get("authorization", "")):
        return {"statusCode": 401, "headers": {"WWW-Authenticate": 'Basic realm="approval"'}, "body": "Unauthorized"}

    # リンクを開いただけでは承認せず、ボタンが押されたら結果を返す
    if event["requestContext"]["http"]["method"] == "GET":
        token = event["queryStringParameters"]["token"]
        return page(PAGE.format(token=html.escape(token)))

    body = event.get("body", "")
    if event.get("isBase64Encoded"):
        body = base64.b64decode(body).decode()
    form = {k: v[0] for k, v in parse_qs(body).items()}
    approved = form["action"] == "approve"
    sfn.send_task_success(taskToken=form["token"], output=json.dumps({"approved": approved}))
    return page("<p>承認しました</p>" if approved else "<p>却下しました</p>")

def authorized(header):
    if not header.startswith("Basic "):
        return False
    try:
        _, _, password = base64.b64decode(header.removeprefix("Basic ")).decode().partition(":")
    except ValueError:
        return False
    return hmac.compare_digest(password, PASSWORD)

def page(body):
    return {"statusCode": 200, "headers": {"Content-Type": "text/html; charset=utf-8"}, "body": body}

承認依頼の宛先メールアドレスを渡してデプロイし、出力されたGatewayのURLなどをリポジトリのルートの .env に書き出します。

デプロイ
pnpm exec cdk deploy -c email=<店長のメールアドレス> --outputs-file outputs.json
cd ..
jq -r '.SfnGatewayToolsStack | "GATEWAY_URL=\(.GatewayUrl)\nSTOCK_ARN=\(.StockArn)"' cdk/outputs.json > .env

デプロイ後にAWSから届く「Subscription Confirmation」のメールで、Confirm subscriptionを押しておきます。

AWSから届いた購読確認のメール。Confirm subscriptionを押す

承認ページのパスワードは、出力された PasswordSecretArn のシークレットから取得します。ユーザー名は任意です。

パスワードの取得
aws secretsmanager get-secret-value --secret-id <PasswordSecretArnの値> \
  --query SecretString --output text

ツールを確認する

エージェント側の実装を用意していきます。リポジトリのルートで依存関係を入れます。

Pythonのセットアップ
uv sync
set -a; source .env; set +a

Pythonを実行するIAMユーザーまたはロールには、作成したGatewayへの bedrock-agentcore:InvokeGateway を許可しておきましょう!

tools/list でツールを確認します。

list_tools.py
list_tools.py
import asyncio
import json
import os

from mcp import ClientSession
from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client

async def main():
    async with aws_iam_streamablehttp_client(
        endpoint=os.environ["GATEWAY_URL"],
        aws_region="us-east-1",
        aws_service="bedrock-agentcore",
    ) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            for tool in tools.tools:
                print(tool.name)
                print("  ", json.dumps(tool.inputSchema, ensure_ascii=False))
                print("  ", tool.description)

asyncio.run(main())
実行コマンド
uv run list_tools.py
実行結果(抜粋)
order___StartExecution
   {"type": "object", "properties": {"input": {"description": "The string that contains the JSON input data of the execution. ...", "type": "string"}}}
   Starts a state machine execution. Returns the executionArn to use with DescribeExecution, GetExecutionHistory and StopExecution.
stock___StartSyncExecution
   {"type": "object", "properties": {"input": {"description": "The string that contains the JSON input data of the execution. ...", "type": "string"}}}
   Starts a Synchronous Express state machine execution and waits for it to complete. Express workflows only; fails on a Standard state machine.

ツール名は「ターゲット名___操作名」になり、StartExecution の引数は input になっていますね。固定したステートマシンのARNは見えなくなっていました。

説明文は操作の汎用的な説明なので、ワークフローが何をするかや input に入れるJSONの形はツールからは分かりません。このあたりはエージェントのシステムプロンプトで伝えたりスキルにしてって感じですかね。

発注担当のエージェントを作る

Gatewayへの接続とツールの絞り込み、日本語のエスケープを gateway_tools.py に実装しています。

エスケープはStrandsのフックで行い、ツールを呼び出す直前に input のJSONをデコードし、json.dumps のデフォルト(ensure_ascii=True)で再エンコードしています。

gateway_tools.py
import json
import os
import re

from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client
from strands.hooks import BeforeToolCallEvent
from strands.tools.mcp import MCPClient

def gateway_client(pattern: str) -> MCPClient:
    # Gatewayのツールのうち、名前がpatternに合うものだけをエージェントに渡す
    return MCPClient(
        lambda: aws_iam_streamablehttp_client(
            endpoint=os.environ["GATEWAY_URL"],
            aws_region="us-east-1",
            aws_service="bedrock-agentcore",
        ),
        tool_filters={"allowed": [re.compile(pattern)]},
    )

def escape_non_ascii(event: BeforeToolCallEvent):
    args = event.tool_use["input"]
    print(f"\n[{event.tool_use['name']}] {json.dumps(args, ensure_ascii=False)}")
    # ワークフローに渡すJSONの日本語を \uXXXX 形式にしてから送る
    if isinstance(args.get("input"), str):
        args["input"] = json.dumps(json.loads(args["input"]))

発注担当のエージェントには stock と order のツールを渡し、システムプロンプトで入力の形を伝えます。

store_agent.py
import sys

from strands import Agent
from strands.hooks import BeforeToolCallEvent

from gateway_tools import escape_non_ascii, gateway_client

SYSTEM_PROMPT = """あなたはスーパーの発注担当です。
- 在庫は stock___StartSyncExecution に {"item": "商品名"} を渡して確認します
- 発注は order___StartExecution に {"item": "商品名", "quantity": 発注数, "unitPrice": 単価, "reason": "発注理由"} を渡して開始します
- 発注を開始したら order___DescribeExecution で結果を確認します
"""

agent = Agent(
    model="us.anthropic.claude-haiku-5-5",
    tools=[gateway_client(r"^(stock|order)___")],
    system_prompt=SYSTEM_PROMPT,
)
agent.hooks.add_callback(BeforeToolCallEvent, escape_non_ascii)
agent(sys.argv[1])

こんなところでフックが役立つとは・・・

牛乳を発注する

まずは承認が要らない金額の発注から試してみます!

実行コマンド
uv run store_agent.py "牛乳の在庫が少ないので、明日の分を発注しておいて"
実行結果(抜粋)
まず牛乳の在庫を確認します。

[stock___StartSyncExecution] {"input": "{\"item\": \"牛乳\"}"}
牛乳の在庫は3本、1日の販売数は40本、単価は180円でした。明日の販売分として40本を発注します。

[order___StartExecution] {"input": "{\"item\": \"牛乳\", \"quantity\": 40, \"unitPrice\": 180, \"reason\": \"在庫残り3本のため、明日分(1日の販売数40本)を補充\"}"}

[order___DescribeExecution] {"executionArn": "arn:aws:states:us-east-1:123456789012:execution:PurchaseOrderWorkflow:4daf7182-...", "includedData": "ALL_DATA"}
牛乳の発注が完了しました。

- **商品**: 牛乳
- **発注数**: 40本
- **単価**: 180円
- **合計**: 7,200円
- **ステータス**: ORDERED(発注済み)

Expressにした在庫照会を StartSyncExecution で呼び出して在庫と単価を確認し、そこから発注数を決めてStandardの発注ワークフローを開始しています。合計7,200円で3万円以下なので、そのまま ORDERED になりました!

StartExecution は実行ARNを返す非同期処理のため、エージェントは続けて DescribeExecution を呼び出して実行結果を確認しています。

米を1週間分発注する

次は承認が必要な金額の発注を試してみます!

実行コマンド
uv run store_agent.py "米5kgが残りわずかなので、1週間分を発注して"
実行結果(抜粋)
在庫を確認します。

[stock___StartSyncExecution] {"input": "{\"item\": \"米5kg\"}"}
在庫は2個、1日の販売数は8個、単価は2,980円でした。1週間分は8×7=56個なので、手持ちの2個を引いて54個を発注します。

[order___StartExecution] {"input": "{\"item\": \"米5kg\", \"quantity\": 54, \"unitPrice\": 2980, \"reason\": \"残りわずかのため1週間分を発注(1日販売8個×7日=56個、在庫2個を差し引いて54個)\"}"}

[order___DescribeExecution] {...}
米5kgを54個発注しました。現在、発注処理は実行中です。

- **在庫**: 2個(残りわずか)
- **1日の販売数**: 8個
- **発注数**: 54個(1週間分56個から在庫2個を引いた数)
- **単価**: 2,980円
- **概算金額**: 160,920円(税抜き、54個×2,980円)

合計160,920円で3万円を超えているため承認待ちになり、RUNNING のまま止まりました。

どこで処理が止まっているのか聞いてみます。

実行コマンド
uv run store_agent.py "米5kgの発注がまだ終わらないみたい。どこで止まってるか調べて"
実行結果(抜粋)
発注の実行一覧を確認します。

[order___ListExecutions] {"statusFilter": "RUNNING"}

[order___DescribeExecution] {...}

[order___GetExecutionHistory] {"executionArn": "arn:aws:states:us-east-1:123456789012:execution:PurchaseOrderWorkflow:beb59a2e-..."}
発注は**承認待ち**で止まっています。エラーではなく、承認者の判断を待っている状態です。

**状況**
- 商品: 米5kg、数量54個、単価2,980円、合計160,920円
- 理由: 残りわずかのため1週間分を発注(1日販売8個×7日=56個、在庫2個を差し引いて54個)
- 現在のステップ: `WaitForApproval`(SNSで承認依頼を送信済み)
- 承認依頼メールの送信は 2026-10-30 09:54:31 (GMT) に完了しています。

**止まっている理由**
承認依頼メールに記載の承認・却下リンクから、承認者が判断するのを待っています。承認か却下の操作があるまで次に進めません。

**今後の動き**
- 承認されると発注が進みます。
- 却下されると発注は行われません。
- 24時間(86,400秒)以内に応答がないとタイムアウトします。

実行ARNを渡していなくても ListExecutions で実行中の発注を探し、GetExecutionHistory で承認待ちの状態で止まっていると特定してくれました。システムプロンプトには履歴の確認を書いていませんでしたが、承認依頼のメールを送った時刻や、承認待ちが24時間でタイムアウトすることまで履歴から読み取れていますね!

店長がメールで承認する

店長のメールには、こんな承認依頼が届きました。

店長に届いた承認依頼のメール。発注内容と承認ページへのリンクが入っている

発注担当のエージェントがワークフローに渡した入力から、商品・数量・合計・理由を本文に入れています。リンクを開くとBasic認証のパスワードを聞かれ、正しく入力すると承認ページが表示されます。

承認ページ。承認するか却下するを押すとSendTaskSuccessで結果を返す

「承認する」を押してから、発注担当のエージェントに結果を聞いてみます。

実行コマンド
uv run store_agent.py "さっきの米5kgの発注はどうなった?"
実行結果(抜粋)
[order___ListExecutions] {}

[order___DescribeExecution] {...}

[order___DescribeExecution] {...}
米5kgの発注は完了しています(ステータス:SUCCEEDED)。

- **商品**:米5kg
- **発注数**:54個(単価 2,980円)
- **合計金額**:160,920円
- **発注理由**:残りわずかだった1週間分を発注(1日販売数8個×7日=56個、在庫2個を差し引いて54個)

店長の承認で止まっていたワークフローが再開して ORDERED になりました!

実行履歴を見ても、承認待ちの WaitForApproval を抜けて PlaceOrder へ進んでいます。

実行コマンド
aws stepfunctions get-execution-history --execution-arn <米5kgの実行ARN> \
  --query 'events[].[id,type,stateEnteredEventDetails.name]' --output text
実行結果(抜粋)
4	TaskStateEntered	WaitForApproval
5	TaskScheduled	None
6	TaskStarted	None
7	TaskSubmitted	None
8	TaskSucceeded	None
9	TaskStateExited	None
10	ChoiceStateEntered	IsApproved
11	ChoiceStateExited	None
12	PassStateEntered	PlaceOrder
13	PassStateExited	None
14	ExecutionSucceeded	None

発注の金額による分岐と承認の手順はステートマシンが担っているので、エージェントが承認を飛ばして発注を確定させることはない作りを簡単に体験できましたね!

ステートマシンを差し替えられないか確認する

最後に固定したステートマシンをエージェント側から変えられないか試します。
ツールを1つ呼び出す call_tool.py で、order___StartExecution に在庫照会のステートマシンのARNを渡してみます。

call_tool.py
call_tool.py
import asyncio
import json
import os
import sys

from mcp import ClientSession
from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client

async def main(name: str, arguments: dict):
    async with aws_iam_streamablehttp_client(
        endpoint=os.environ["GATEWAY_URL"],
        aws_region="us-east-1",
        aws_service="bedrock-agentcore",
    ) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            result = await session.call_tool(name, arguments)
            print("isError:", result.isError)
            for c in result.content:
                print(c.text)

asyncio.run(main(sys.argv[1], json.loads(sys.argv[2])))
実行コマンド
uv run call_tool.py order___StartExecution "{\"input\": \"{}\", \"stateMachineArn\": \"$STOCK_ARN\"}"
実行結果
isError: True
IllegalArgumentException - Invalid request parameters: cannot set parameter(s): [/stateMachineArn]

Gatewayが呼び出しを拒否しました!変なマシンとか呼び出されないようになっていますね!

Cedarで発注の取り消しを店長だけに許可する

Gatewayに付けたPolicy Engine(Cedar)でツールごとに呼び出せる人を制限してみます。
発注の取り消し(order___StopExecution)は店長のロールだけに許可し、発注担当のエージェントからは呼び出せないようにします。

上段は発注担当エージェントが取り消し(StopExecution)を呼び出すと、GatewayのCedarの評価で店長以外として拒否される。取り消しのツールはツールの一覧にも出てこない。下段は店長のロールで呼び出すと許可され、発注ワークフローがABORTEDになる

ポリシーはCDKで作っています。Cedarはデフォルトで拒否なので、まずこのGatewayのツールをIAMの呼び出し元に許可し、そのうえで取り消しだけ店長以外を拒否します。

lib/stack.ts(Cedarの部分)
// Cedarのポリシー
const gatewayEntity = `AgentCore::Gateway::"${gateway.gatewayArn}"`;
const cedar = (id: string, statement: string, ignoreFindings = false) =>
  new agentcore.Policy(this, id, {
    policyEngine,
    statement: agentcore.PolicyStatement.fromCedar(statement),
    validationMode: ignoreFindings ? agentcore.PolicyValidationMode.IGNORE_ALL_FINDINGS : undefined,
  });
// Cedarはデフォルト拒否なので、このGatewayのツールをIAMの呼び出し元に許可しておく
cedar("AllowGatewayTools", `permit(principal is AgentCore::IamEntity, action, resource == ${gatewayEntity});`, true);
// 発注の取り消しは店長のロールだけ(ポリシーはツールと突き合わせて検証されるので、ターゲットの後に作る)
cedar("OnlyManagerCanStop", `forbid(principal is AgentCore::IamEntity, action == AgentCore::Action::"order___StopExecution", resource == ${gatewayEntity})
  unless { principal.id like "*sfn-gateway-tools-manager*" };`).node.addDependency(orderTarget);

Policyはツールの定義と照合されるため、ターゲットの作成後に作るよう依存関係を付けています。

ロール名の部分一致で店長かどうかを判定しています。

Gatewayの認証をJWTにした場合も同じように書けます。呼び出し元は AgentCore::OAuthUser になり、トークンのクレームを principal.getTag("<クレーム名>") で参照できます。ロール名の代わりにクレームで店長かどうかを判定する形です。

承認待ちの米の発注がある状態で、発注担当のエージェントに取り消しを頼んでみます。

実行コマンド
uv run store_agent.py "さっきの米5kgの発注、やっぱり取り消して"
実行結果(抜粋)
[order___ListExecutions] {"statusFilter": "RUNNING"}

[order___DescribeExecution] {...}
米5kgの発注(54袋、単価2,980円、合計160,920円)はまだ実行中で、取り消せていません。

私の持っているツールには、実行中の発注を停止する機能がありません。

ポリシーで禁止したツールは tools/list にも出てこなくなり、発注担当のエージェントからは取り消しのツール自体が見えていませんでした。call_tool.py で直接呼び出しても拒否されます。

発注担当のロールで order___StopExecution を呼び出した結果
Tool Execution Denied: Tool call not allowed due to policy enforcement [Policy evaluation denied due to SfnGatewayToolsStackOnlyManagerCanStopFD3C1D49-...]

店長のロールに切り替えると tools/list に order___StopExecution が出てきて、取り消しも成功しました。

店長のロールで取り消す
# サブシェルで実行し、終わったら元の認証情報に戻す
(
  CREDS=$(aws sts assume-role --role-arn <ManagerRoleArnの値> --role-session-name manager --query Credentials)
  export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r .AccessKeyId)
  export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r .SecretAccessKey)
  export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r .SessionToken)
  uv run call_tool.py order___StopExecution '{"executionArn": "<米5kgの実行ARN>"}'
)
実行結果
isError: False
{"stopDate":1.793360833268E9}

発注の実行は ABORTED になりました!承認の手順はワークフロー・誰がどのツールを使えるかはCedarと、役割を分けて管理できるのはいいですね。

入力の中身ではCedarの条件を書けなかった

発注理由(reason)のない発注を拒否するポリシーも試しましたが、こちらは動きませんでした。Step Functionsのツールの入力は input というJSONの文字列なので、context.input.input に like で文字列の条件を書いています。

試したポリシー
forbid(principal is AgentCore::IamEntity, action == AgentCore::Action::"order___StartExecution", resource == AgentCore::Gateway::"<GatewayのARN>")
  unless { context has input && context.input has input && context.input.input like "*\"reason\"*" };

ポリシーは作成できたものの、発注理由があってもなくても拒否されました。

実行結果
Tool Execution Denied: Tool call not allowed due to policy enforcement [Authorization denied due to policy evaluation errors:
Value does not match expected type]

条件を減らして試すと context has input までは評価でき、context.input has input を入れた時点で同じ評価エラーになりました。ポリシーの検証では context.input は {input?: String} の形として扱われていますが、実際の評価では型が合わないようです。評価エラーのときは拒否されるので、この条件を入れると発注がすべて止まってしまいます。

現時点では、金額や理由など入力の中身による判断はワークフロー側に持たせるのが良さそうですが、もしうまくいくやり方あれば教えてください・・・!

後片付け

スタックを削除します。

実行コマンド
cd cdk
pnpm exec cdk destroy -c email=<店長のメールアドレス>

使いどころを考えてみる

どういったところが嬉しいんでしょうか。少しだけ考えてみます。

裏側でAWSのサービスを直接呼び出せる

Step FunctionsはDynamoDBなどの最適化された統合に加え、AWS SDK統合で200以上のサービスのAPIをLambdaなしで呼び出せます。今回の在庫照会もDynamoDBの GetItem を直接呼び出しているだけなので、S3やほかのサービスのデータを取得する処理も同じようにツールにできそうです。
コードを書かずともマネージドに他のサービスと連携して、かつエージェントの処理と切り出せるのは良いかもなと感じました。

https://docs.aws.amazon.com/step-functions/latest/dg/connect-to-resource.html

既存のワークフローと連携できる

すでに動いているワークフローをステートマシンに手を入れずにエージェントからも呼び出せるようになるのは嬉しいかなと思いました!

これまでの入口として、EventBridgeのスケジュールが発注バッチを、業務画面の担当者が返品の承認フローを開始している。今回はエージェントがMCPでAgentCore Gatewayを呼び出し、Gatewayが既存のワークフローをStartExecutionなどで動かす入口を足す。既存のワークフローには手を入れない

たとえば毎朝の発注バッチ・月末の売上集計・返品の承認フローがすでにStep Functionsで動いているものがあるとします。
これまでEventBridgeのスケジュールや業務画面から起動していたワークフローにGatewayといった入口を足すことでAIエージェントから簡単に呼び出せるようになります。

特に業務ロジックを変えたりエージェント側に実装し直したりせずに、呼び出しや判断を任せられるのは良さそうだと思いました!

ただつなぐときは次の点に注意が必要です。ドキュメントからピックアップして紹介しています。

  • 1つの操作に固定できるステートマシンは1つなので複数のワークフローを StartExecution で公開するときはワークフローごとにターゲットを作る必要がある
  • ツールの説明は汎用的なのでinput に入れるJSONの形はシステムプロンプトやスキルでエージェントに伝える
  • Gatewayのターゲットはサービスロールを共有するので権限の境界を分けたいワークフローはGatewayごと分ける
  • GatewayはStep Functionsを同じリージョンで呼び出すので別のリージョンのステートマシンはつなげない。ステートマシンと同じリージョンにGatewayを作る
  • 既存のワークフローがExpressだと ListExecutions・DescribeExecution・GetExecutionHistory・StopExecution は使えない。すぐ結果がほしい処理は StartSyncExecution で受け取り、状況を追わせたい処理はStandardで作る

https://docs.aws.amazon.com/step-functions/latest/dg/tutorial-agentcore-gateway.html

AIエージェントに全部を任せたくない処理を切り出せる

もちろん既存のワークフローだけでなく新しく作る処理でも使えます!
発注のように手順はルールどおりに確実に実行したいけれど、実行するかどうかの判断はAIエージェントに任せたいケースがあった際は手順をワークフローに固めておけば、判断はエージェント・決まった手順はワークフローと役割を分けられるのは嬉しいポイントになるのかなと!

発注担当エージェントは在庫を見て発注するかを判断し、発注ワークフローを開始する。ワークフローは合計の判定・店長の承認待ち・発注の確定をルールどおりに進め、エージェントは手順を飛ばせない

私のふわっとした理解では上記メリットがあるのかなと思いました!
色々とユースケースふかぼっていきたいですね!

おわりに

GatewayのターゲットにStep Functionsを追加して簡単にツール化できるようになったのはいいですね!既存のワークフローとの連携や固定化させたい手順などをAIエージェントに実行させるのに良さそうですし広がりを感じました!良きユースケースを模索していきたいですね!

本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!

この記事をシェアする

関連記事