0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Step Functions と Bedrock AgentCore harness で作る AI エージェントの「決定論的」と「非決定的」の理解

0
Last updated at Posted at 2026-08-28

はじめに

この記事は是非以下の記事とセットで読んでください

https://qiita.com/leomarokun/items/bcd9ae7dd4244e08cb52

AI エージェントを業務ワークフローに組み込むときに、考えるべき点として「どこまでをエージェントに任せるか」です。

この記事では「今週の AWS 生成AI アップデートを調べて Slack に通知する」を題材に、調べる部分だけを AI エージェントに任せ、通知は従来どおり機械的に実行する構成を Terraform で作ってみます。

「決定論的」と「非決定的」

ChatGPT Image 2026年8月28日 02_52_35.png

同じ入力に対して常に同じ結果が返る性質を 決定論的
同じ入力でも結果が一通りに定まらない性質を 非決定的
と呼びます。

Slack に POST する処理は決定論的です。同じ文面を渡せば同じ投稿になり、失敗すれば HTTP のステータスコードで分かります。

一方「今週の話題をまとめる」処理は非決定的です。何を重要と判断するかは毎回揺れますし、そもそも今週何が起きたかは実行してみないと分かりません。

エージェントが向いているのは非決定的なほうです。決定論的であってほしい処理まで一緒に AI エージェントへ任せてしまうと、失敗しても誰にも気づかれない「静かに何も起きない」状態へと陥ります。

通知までエージェントに任せると何が起きるか

「調べて Slack に通知して」と一息に指示すると、通知するかどうかもモデルの判断になります。調査がうまくいかなかったとき、モデルは「通知するほどの内容がないので終了します」と勝手に判断して、何も起こさずに正常終了してしまうことがあります。

Slack への通知を AgentCore の外側、つまり Step Functions のステートとして分離しておけば、AI エージェントが成功しても失敗しても必ず同じ経路を通ります。

やってみよう

0. 簡易構成図

image.png

1. 非決定的パート: Bedrock AgentCore harness

完成版は「3. デプロイする」で示す main.tf をコピーして実行すれば作れるようにしてますので、ここではポイントだけを整理します。

Bedrock AgentCore Harness は、モデル・ツール・メモリなどを設定するだけで動くマネージドなエージェントループで、オーケストレーションのコードを書かずに済むのが特徴です。

次の通り、リモート MCP サーバーは URL を書くだけで繋がります。

  tool {
    type = "remote_mcp"
    name = "AWSKnowledgeMCPServer"

    config {
      remote_mcp {
        url = "https://knowledge-mcp.global.api.aws"
      }
    }
  }

2. 決定論的パート: Step Functions

ここも完成版は「3. デプロイする」で示す main.tf をコピーして実行すれば作れるようにしてますので、ここではポイントだけを整理します。

Bedrock AgentCore Harness の呼び出し自体はシンプルで、arn:aws:states:::bedrockagentcore:invokeHarness を Task の Resource に指定するだけです(AWS マネージメントコンソールの Workflow Studio では AgentCore InvokeHarness で検索すると出てきます)。

ステートマシンの中身はざっくりとこんなイメージになります。

このセクションで抑えておきたいポイントを 2 つだけ整理しておきます。

  • 日付はワークフロー側から渡す: 「今日が何日か」を調べるのはエージェントの仕事ではないので、JSONata の $now() でプロンプトに埋め込みます
  • 失敗も同じ通知経路に流す: SummarizeUpdatesCatch を付けて States.ALL を拾い、NotifyFailure を経由して必ず Slack に届くようにします
        Catch = [{
          ErrorEquals = ["States.ALL"]
          Next        = "NotifyFailure"
          Output      = "{% { 'text': '調査に失敗しました: ' & $states.errorOutput.Error } %}"
        }]

3. デプロイする

Slack の Incoming Webhook URL を用意して、以下をそのままコピーして実行すれば動くようにしてあります。

main.tf(全文)
// Terraform 設定ファイル
terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 6.61"
    }
  }
}

// AWS プロバイダの設定
provider "aws" {
  region = var.region
}

// 変数定義
variable "region" {
  type    = string
  default = "ap-northeast-1"
}

variable "slack_webhook_url" {
  type      = string
  sensitive = true
}

variable "model_id" {
  type    = string
  default = "global.amazon.nova-2-lite-v1:0"
}

// ローカル変数定義
locals {
  name         = "weekly-aws-news"
  harness_name = "weekly_aws_news"
}

// 現在の AWS アカウント情報を取得
data "aws_caller_identity" "current" {}

########################################
# 1. AgentCore harness (非決定的パート)
########################################

// Bedrock AgentCore Harness の IAM ロールを作成
resource "aws_iam_role" "harness" {
  name = "${local.name}-harness-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "bedrock-agentcore.amazonaws.com" }
      Action    = "sts:AssumeRole"
      Condition = {
        StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id }
        ArnLike      = { "aws:SourceArn" = "arn:aws:bedrock-agentcore:${var.region}:${data.aws_caller_identity.current.account_id}:*" }
      }
    }]
  })
}

// Bedrock AgentCore Harness の IAM ポリシーを作成
resource "aws_iam_role_policy" "harness" {
  role = aws_iam_role.harness.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "BedrockModelInvocation"
        Effect = "Allow"
        Action = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"]
        Resource = [
          "arn:aws:bedrock:*::foundation-model/*",
          "arn:aws:bedrock:${var.region}:${data.aws_caller_identity.current.account_id}:*",
        ]
      },
      {
        Sid      = "EcrPublicPull"
        Effect   = "Allow"
        Action   = ["ecr-public:GetAuthorizationToken", "sts:GetServiceBearerToken"]
        Resource = "*"
      },
      {
        Sid      = "XRayTracing"
        Effect   = "Allow"
        Action   = ["xray:PutTraceSegments", "xray:PutTelemetryRecords", "xray:GetSamplingRules", "xray:GetSamplingTargets"]
        Resource = "*"
      },
      {
        Sid      = "Metrics"
        Effect   = "Allow"
        Action   = "cloudwatch:PutMetricData"
        Resource = "*"
        Condition = {
          StringEquals = { "cloudwatch:namespace" = "bedrock-agentcore" }
        }
      },
      {
        Sid    = "Logs"
        Effect = "Allow"
        Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogStreams", "logs:DescribeLogGroups"]
        Resource = [
          "arn:aws:logs:${var.region}:${data.aws_caller_identity.current.account_id}:log-group:/aws/bedrock-agentcore/runtimes/*",
          "arn:aws:logs:${var.region}:${data.aws_caller_identity.current.account_id}:log-group:*",
        ]
      },
      {
        Sid    = "AgentCoreMemory"
        Effect = "Allow"
        Action = [
          "bedrock-agentcore:CreateEvent",
          "bedrock-agentcore:GetEvent",
          "bedrock-agentcore:ListEvents",
          "bedrock-agentcore:DeleteEvent",
          "bedrock-agentcore:RetrieveMemoryRecords",
        ]
        Resource = "arn:aws:bedrock-agentcore:${var.region}:${data.aws_caller_identity.current.account_id}:memory/${local.harness_name}-*"
      },
      {
        Sid    = "WorkloadIdentity"
        Effect = "Allow"
        Action = ["bedrock-agentcore:GetWorkloadAccessToken", "bedrock-agentcore:GetWorkloadAccessTokenForJWT"]
        Resource = [
          "arn:aws:bedrock-agentcore:${var.region}:${data.aws_caller_identity.current.account_id}:workload-identity-directory/default",
          "arn:aws:bedrock-agentcore:${var.region}:${data.aws_caller_identity.current.account_id}:workload-identity-directory/default/workload-identity/*",
        ]
      },
    ]
  })
}

// Bedrock AgentCore Harness を作成
resource "aws_bedrockagentcore_harness" "news" {
  harness_name       = local.harness_name
  execution_role_arn = aws_iam_role.harness.arn

  model {
    bedrock_model_config {
      model_id = var.model_id
    }
  }

  system_prompt {
    text = <<-EOT
      あなたは AWS の最新情報を調べて日本語で要約するアシスタントです。
      調査には必ず AWS Knowledge MCP のツールを使い、推測で書かないでください。
      出力は Slack にそのまま投稿されます。前置き・締めの挨拶・見出しは書かず、本文だけを返してください。
    EOT
  }

  tool {
    type = "remote_mcp"
    name = "AWSKnowledgeMCPServer"

    config {
      remote_mcp {
        url = "https://knowledge-mcp.global.api.aws"
      }
    }
  }

  allowed_tools   = ["*"]
  max_iterations  = 15
  timeout_seconds = 600
}

########################################
# 2. Slack 通知の接続情報 (決定論的パート)
########################################

// Slack Incoming Webhook は URL 自体が資格情報なので、EventBridge connection はダミーの値を1つ入れておけば通ります
resource "aws_cloudwatch_event_connection" "slack" {
  name               = "${local.name}-slack"
  authorization_type = "API_KEY"

  auth_parameters {
    api_key {
      key   = "x-unused-auth"
      value = "unused"
    }
  }
}

########################################
# 3. Step Functions ワークフロー
########################################

// Step Functions の実行ロールを作成
resource "aws_iam_role" "sfn" {
  name = "${local.name}-sfn-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "states.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

// Step Functions の実行ロールにポリシーをアタッチ
resource "aws_iam_role_policy" "sfn" {
  role = aws_iam_role.sfn.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "InvokeHarness"
        Effect   = "Allow"
        Action   = ["bedrock-agentcore:InvokeHarness", "bedrock-agentcore:InvokeAgentRuntime"]
        Resource = "${aws_bedrockagentcore_harness.news.arn}*"
      },
      {
        Sid      = "CallSlack"
        Effect   = "Allow"
        Action   = "states:InvokeHTTPEndpoint"
        Resource = "arn:aws:states:${var.region}:${data.aws_caller_identity.current.account_id}:stateMachine:${local.name}"
        Condition = {
          StringEquals = { "states:HTTPMethod" = "POST" }
          StringLike   = { "states:HTTPEndpoint" = "https://hooks.slack.com/*" }
        }
      },
      {
        Sid      = "UseConnection"
        Effect   = "Allow"
        Action   = "events:RetrieveConnectionCredentials"
        Resource = aws_cloudwatch_event_connection.slack.arn
      },
      {
        Sid      = "ReadConnectionSecret"
        Effect   = "Allow"
        Action   = ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"]
        Resource = "arn:aws:secretsmanager:${var.region}:${data.aws_caller_identity.current.account_id}:secret:events!connection/*"
      },
    ]
  })
}

// Step Functions のステートマシンを作成
resource "aws_sfn_state_machine" "news" {
  name     = local.name
  role_arn = aws_iam_role.sfn.arn

  definition = jsonencode({
    QueryLanguage = "JSONata"
    Comment       = "非決定的な調査は harness に、決定論的な通知は Step Functions に"
    StartAt       = "SummarizeUpdates"
    States = {
      # 非決定的パート: 何を何回調べるかはモデルが決める
      SummarizeUpdates = {
        Type     = "Task"
        Resource = "arn:aws:states:::bedrockagentcore:invokeHarness"
        Arguments = {
          HarnessArn       = aws_bedrockagentcore_harness.news.arn
          RuntimeSessionId = "{% $uuid() %}"
          Messages = [{
            Role = "user"
            Content = [{
              Text = "{% '今日は ' & $substring($now(), 0, 10) & ' です。直近1週間に発表された AWS の生成AI関連アップデートを AWS Knowledge MCP で調べ、重要なものを3件に絞って日本語の箇条書きにまとめてください。各項目はサービス名から始め、1〜2文で説明してください。' %}"
            }]
          }]
        }
        Output = "{% { 'text': '今週の生成AIアップデート\n\n' & $states.result.Output.Message.Content[0].Text } %}"
        Retry = [{
          ErrorEquals     = ["BedrockAgentCore.ThrottlingException"]
          IntervalSeconds = 5
          MaxAttempts     = 3
          BackoffRate     = 2
        }]
        Catch = [{
          ErrorEquals = ["States.ALL"]
          Next        = "NotifyFailure"
          Output      = "{% { 'text': '調査に失敗しました: ' & $states.errorOutput.Error } %}"
        }]
        Next = "NotifySlack"
      }

      # 決定論的パート: 成功しても失敗しても必ず同じ経路で通知する
      NotifySlack = {
        Type     = "Task"
        Resource = "arn:aws:states:::http:invoke"
        Arguments = {
          ApiEndpoint      = var.slack_webhook_url
          Method           = "POST"
          InvocationConfig = { ConnectionArn = aws_cloudwatch_event_connection.slack.arn }
          Headers          = { "Content-Type" = "application/json" }
          RequestBody      = { text = "{% $states.input.text %}" }
        }
        Retry = [{
          ErrorEquals     = ["States.Http.StatusCode429", "States.Http.StatusCode500", "States.Http.StatusCode503"]
          IntervalSeconds = 3
          MaxAttempts     = 3
          BackoffRate     = 2
        }]
        End = true
      }

      NotifyFailure = {
        Type     = "Task"
        Resource = "arn:aws:states:::http:invoke"
        Arguments = {
          ApiEndpoint      = var.slack_webhook_url
          Method           = "POST"
          InvocationConfig = { ConnectionArn = aws_cloudwatch_event_connection.slack.arn }
          Headers          = { "Content-Type" = "application/json" }
          RequestBody      = { text = "{% $states.input.text %}" }
        }
        Next = "Fail"
      }

      Fail = {
        Type  = "Fail"
        Error = "AgentInvocationFailed"
      }
    }
  })
}

// 出力値を定義
output "state_machine_arn" {
  value = aws_sfn_state_machine.news.arn
}

output "harness_arn" {
  value = aws_bedrockagentcore_harness.news.arn
}
# ディレクトリを作って移動
mkdir -p sfn-harness-tf && cd sfn-harness-tf

# 上記の main.tf をこのディレクトリに保存してから

# terraform.tfvars を作って Slack の Incoming Webhook URL を書き込む
cat > terraform.tfvars <<EOF
slack_webhook_url = "https://hooks.slack.com/services/XXXX/YYYY/ZZZZ"
EOF

# Terraform を初期化してデプロイ
terraform init
terraform apply -auto-approve

4. 実行し結果を確認する

ステートマシンを実行します。

aws stepfunctions start-execution \
  --state-machine-arn $(terraform output -raw state_machine_arn) \
  --region ap-northeast-1

AWS マネージメントコンソールの Step Functions から実行履歴を確認してみましょう。SummarizeUpdatesNotifySlack の順に成功していることが確認できます。

スクリーンショット 2026-08-28 2.14.05.png

Slack へ通知された内容はこんな感じです。

スクリーンショット 2026-08-28 2.31.23.png

5. AI エージェントが失敗しても通知が届くことを確認する

「AI エージェントが失敗しても通知は届く」がこの構成の要なので、わざと失敗させて確かめてみます。harness の実行ロールに、モデル呼び出しを拒否する Deny ポリシーを一時的に付けます。

# harness の実行ロールに一時的に Deny ポリシーを付与
cat > deny-bedrock.json <<'JSON'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Deny",
    "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
    "Resource": "*"
  }]
}
JSON

aws iam put-role-policy \
  --role-name weekly-aws-news-harness-role \
  --policy-name deny-bedrock-temporarily \
  --policy-document file://deny-bedrock.json

再度ステートマシンを実行します。

aws stepfunctions start-execution \
  --state-machine-arn $(terraform output -raw state_machine_arn) \
  --region ap-northeast-1

AWS マネージメントコンソールの Step Functions から実行履歴を確認してみましょう。SummarizeUpdatesNotifyFailure で通知が届き、Fail でワークフローとしては失敗していることが確認できます。

スクリーンショット 2026-08-28 2.34.42.png

Slack へ通知された内容はこんな感じです。

スクリーンショット 2026-08-28 2.34.23.png

確認できたら Deny ポリシーを外して元に戻します。これを忘れると、以降 harness を呼ぶたびに同じ理由で失敗し続けてしまいます。

aws iam delete-role-policy \
  --role-name weekly-aws-news-harness-role \
  --policy-name deny-bedrock-temporarily

後片付け

terraform destroy -auto-approve

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?