🚀

【Amazon Bedrock AgentCore/Strands Agents】AIエージェントで経理業務の課題を解決

に公開

はじめに

Amazon Bedrock AgentCoreがGAしたため、社内で運用している経理エージェントをAmazon Bedrock AgentCoreおよびStrands Agentsで実装してみました。

Amazon Bedrock AgentCore

今回は以下のサービスを利用しています。

サービス 今回の用途
AgentCore Runtime 経理エージェントのホスト
AgentCore Gateway Lambda関数の呼び出し(経理データの取得)
AgentCore Identity 経理エージェントを呼び出すクライアントの認証(インバウンド認証)
Gatewayを呼び出す経理エージェントの認証(インバウンド認証)
GatewayがLambdaを呼び出す際の接続認証(アウトバウンド認証)

会話履歴等の永続化はStrands AgentsのS3SessionManagerを利用しております。
経理エージェントの担当領域が拡大し、長期記憶が必要になった際はAgentCore Memoryの導入を進めたいと考えております。

対象の経理業務

説明の便宜上、経理業務を時系列で以下の4区分に分けております。

区分 具体例 本記事の対象
①仕訳の登録準備 請求書の取りまとめや請求明細の確認など No
②仕訳の登録 会計システムに仕訳を登録 No
③仕訳のレビュー 仕訳の登録内容(日付、取引先、部門など)をレビュー Yes
④経理データの活用 月次の業績報告、取引先別の分析など Yes

以下の理由により、社内で運用している経理エージェントは③と④の業務を対象にしております。

  • ①および②の業務は、SaaSやAIツールの導入により、社内では既に自動化・効率化が進んでおりました。
  • ③の業務は、定期的に発生する業務であり、レビューする仕訳の量も多いため、社内で効率化の声が挙がっておりました。
  • ④の業務は、発生頻度は主に月次のため①から③よりも頻度は下がりますが、決算締め後に短期間で実施する必要がありました。

事前準備

CDKの初期化

mkdir accounting-agent && cd accounting-agent
npx cdk init app --language typescript

AWSリソースの準備

  • AgentCoreはL1 Constructで実装しております。
  • AgentCoreのL2 Constructはalphaモジュールが早速リリースされているため、別記事でL2 Constructを使った実装を投稿予定です。
  • 以下ではデプロイ頻度やリリース責務に応じてStackで実装するかConstructで実装するかを判断しております。

S3の作成

用途

  • Strands Agents のセッション(会話履歴等)の保存
  • S3SessionManager
実装コード
lib/storage/session.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';

import {
  getStage,
  getS3Encryption,
  getS3LifecycleRules,
  getS3AutoDeleteObjects,
  getRemovalPolicy,
} from '../utils';

export interface SessionStackProps extends cdk.StackProps {
  stageName: string;
}

export class SessionStack extends cdk.Stack {
  public readonly bucket: s3.Bucket;

  constructor(scope: Construct, id: string, props: SessionStackProps) {
    super(scope, id, props);

    const stage = getStage(props.stageName);

    this.bucket = new s3.Bucket(this, 'Bucket', {
      bucketName: `${this.account}-${this.region}-${stage}-accounting-agent-session`,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
      versioned: true,
      enforceSSL: true,
      encryption: getS3Encryption(stage),
      lifecycleRules: getS3LifecycleRules(stage),
      autoDeleteObjects: getS3AutoDeleteObjects(stage),
      removalPolicy: getRemovalPolicy(stage),
    });

    new cdk.CfnOutput(this, 'BucketArn', {
      value: this.bucket.bucketArn,
    });

    new cdk.CfnOutput(this, 'BucketName', {
      value: this.bucket.bucketName,
    });
  }
}

Cognitoの作成

以下が今回のCognitoを用いた認証の全体像です。

用途(クライアント - エージェント間)

  • クライアントがAgentCore Runtimeのエージェントを呼び出す際のJWT認証
  • Cognitoの認可コードフローを利用(簡便的に開発環境はuserPasswordを許可)

用途(エージェント - Gateway間)

  • AgentCore RuntimeのエージェントがAgentCore Gatewayを利用する際の認証
  • Cognitoのクライアントクレデンシャルフローを利用
実装コード
lib/auth/index.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cognito from 'aws-cdk-lib/aws-cognito';

import { getStage, isProd, getRemovalPolicy } from '../utils';

interface AuthStackProps extends cdk.StackProps {
  stageName: string;
}

const DOMAIN = `accounting-agent`;
const SCOPE_NAME = 'accounting-agent.default';
const SCOPE_DESCRIPTION = 'Accounting Agent API Default Scope';
const SERVER_IDENTIFIER = 'accounting-agent-api';
const SERVER_NAME = 'accounting-agent';

export class AuthStack extends cdk.Stack {
  public readonly userPool: cognito.UserPool;
  public readonly userPoolDomain: cognito.UserPoolDomain;
  // クライアントがエージェントを呼び出すためのクライアント
  public readonly userPoolClientForUser: cognito.UserPoolClient;
  // エージェントがゲートウェイを呼び出すためのクライアント
  public readonly userPoolClientForAgent: cognito.UserPoolClient;

  constructor(scope: Construct, id: string, props: AuthStackProps) {
    super(scope, id, props);

    const stage = getStage(props.stageName);
    const isProduction = isProd(props.stageName);

    this.userPool = new cognito.UserPool(this, 'UserPool', {
      userPoolName: `${stage}-accounting-agent`,
      selfSignUpEnabled: false,
      signInAliases: {
        email: true,
      },
      autoVerify: {
        email: true,
      },
      standardAttributes: {
        email: { required: true, mutable: true },
      },
      removalPolicy: getRemovalPolicy(stage),
      deletionProtection: isProduction,
    });

    this.userPoolClientForUser = new cognito.UserPoolClient(
      this,
      'UserPoolClientForUser',
      {
        userPool: this.userPool,
        authFlows: {
          userSrp: true,
          userPassword: isProduction ? false : true,
        },
        oAuth: {
          flows: {
            authorizationCodeGrant: true,
          },
          scopes: [
            cognito.OAuthScope.OPENID,
            cognito.OAuthScope.PROFILE,
            cognito.OAuthScope.EMAIL,
          ],
          callbackUrls: ['http://localhost:3000/callback'],
          logoutUrls: ['http://localhost:3000'],
        },
        supportedIdentityProviders: [
          cognito.UserPoolClientIdentityProvider.COGNITO,
        ],
        preventUserExistenceErrors: true,
      }
    );

    this.userPoolDomain = this.userPool.addDomain('UserPoolDomain', {
      cognitoDomain: {
        domainPrefix: `${stage}-${DOMAIN}`,
      },
    });

    const resourceServerScope = new cognito.ResourceServerScope({
      scopeName: SCOPE_NAME,
      scopeDescription: SCOPE_DESCRIPTION,
    });

    const resourceServer = this.userPool.addResourceServer('ResourceServer', {
      identifier: `${stage}-${SERVER_IDENTIFIER}`,
      userPoolResourceServerName: SERVER_NAME,
      scopes: [resourceServerScope],
    });

    this.userPoolClientForAgent = new cognito.UserPoolClient(
      this,
      'UserPoolClientForAgent',
      {
        userPool: this.userPool,
        generateSecret: true,
        authFlows: {
          adminUserPassword: false,
          custom: false,
          userPassword: false,
          userSrp: false,
          user: false,
        },
        oAuth: {
          flows: {
            authorizationCodeGrant: false,
            implicitCodeGrant: false,
            clientCredentials: true,
          },
          scopes: [
            cognito.OAuthScope.resourceServer(
              resourceServer,
              resourceServerScope
            ),
          ],
        },
        enableTokenRevocation: true,
      }
    );

    new cdk.CfnOutput(this, 'UserPoolId', {
      value: this.userPool.userPoolId,
    });

    new cdk.CfnOutput(this, 'UserPoolClientIdForUser', {
      value: this.userPoolClientForUser.userPoolClientId,
    });

    new cdk.CfnOutput(this, 'UserPoolClientIdForAgent', {
      value: this.userPoolClientForAgent.userPoolClientId,
    });
  }
}

ECRの作成

用途

  • AgentCore Runtimeのデプロイで利用
実装コード
lib/ecr/index.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ecr from 'aws-cdk-lib/aws-ecr';

import {
  getStage,
  getEcrLifecycleRules,
  getEcrEncryption,
  getEcrEmptyOnDelete,
  getRemovalPolicy,
} from '../utils';

export interface EcrStackProps extends cdk.StackProps {
  stageName: string;
}

export class EcrStack extends cdk.Stack {
  public readonly repository: ecr.Repository;

  constructor(scope: Construct, id: string, props: EcrStackProps) {
    super(scope, id, props);

    const stage = getStage(props.stageName);

    this.repository = new ecr.Repository(this, 'Repository', {
      repositoryName: `${stage}-accounting-agent`,
      imageScanOnPush: true,
      imageTagMutability: ecr.TagMutability.IMMUTABLE,
      lifecycleRules: getEcrLifecycleRules(stage),
      encryption: getEcrEncryption(stage),
      emptyOnDelete: getEcrEmptyOnDelete(stage),
      removalPolicy: getRemovalPolicy(stage),
    });

    new cdk.CfnOutput(this, 'RepositoryUri', {
      value: this.repository.repositoryUri,
    });
  }
}

CodeBuildの作成

用途

  • AgentCore RuntimeのソースコードからコンテナイメージをビルドしてECRに登録
実装コード
bin/build/index.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3assets from 'aws-cdk-lib/aws-s3-assets';
import * as path from 'path';
import * as ecr from 'aws-cdk-lib/aws-ecr';

import { BuildProjectConstruct } from './project';
import { BuildHandlerConstruct } from './handler';
import { BuildCustomResourceConstruct } from './custom';

export interface BuildStackProps extends cdk.StackProps {
  stageName: string;
  repository: ecr.IRepository;
}

export class BuildStack extends cdk.Stack {
  public readonly versionTag: string;

  constructor(scope: Construct, id: string, props: BuildStackProps) {
    super(scope, id, props);

    const sourceAsset = new s3assets.Asset(this, 'Asset', {
      path: path.join(__dirname, '../..', 'agentcore'),
    });
    this.versionTag = sourceAsset.assetHash;

    const buildProject = new BuildProjectConstruct(this, 'BuildProject', {
      stageName: props.stageName,
      repository: props.repository,
      sourceAsset: sourceAsset,
      versionTag: this.versionTag,
    });

    const buildHandler = new BuildHandlerConstruct(this, 'BuildHandler', {
      stageName: props.stageName,
      buildProjectArn: buildProject.project.projectArn,
      buildProjectName: buildProject.project.projectName,
    });

    const buildCustomResource = new BuildCustomResourceConstruct(
      this,
      'BuildCustomResource',
      {
        onEventHandler: buildHandler.onEventHandler,
        isCompleteHandler: buildHandler.isCompleteHandler,
        versionTag: this.versionTag,
      }
    );
    buildCustomResource.node.addDependency(sourceAsset);
    buildCustomResource.node.addDependency(buildProject);
    buildCustomResource.node.addDependency(buildHandler);
  }
}

AgentCore Gateway

公式ドキュメント

With Gateway, developers can convert APIs, Lambda functions, and existing services into Model Context Protocol (MCP)-compatible tools and make them available to agents through Gateway endpoints with just a few lines of code.

Gatewayを使用すると、APIやLambda関数をMCP対応ツールに変換し、Gatewayエンドポイント経由でエージェントが利用できるようにできます。

Manage both inbound authentication (verifying agent identity) and outbound authentication (connecting to tools) in a single service. Handle OAuth flows, token refresh, and secure credential storage for third-party services.

また、エージェントの認証とツールへの接続認証を管理できます。

今回は以下の用途でAgentCore Gatewayを利用しました。

  1. Lambdaで会計システムから仕訳データを取得する処理を実装
  2. AgentCore Runtimeで稼働しているエージェントがLambdaをツールとして呼び出して仕訳データを取得

Lambda関数

用途

  • Lambdaで会計システムから仕訳データを取得する処理を実装
  • 既存のLambdaがある場合、Lambdaが取得するeventやcontextを微調整することで、AIエージェントのツールとして利用することが可能です

実装コード

実装コード
lambda/agent/accounting/handler.ts
import { extractTargetAndToolName } from './utils';
import { testJournals, Journal } from './data';

// GatewayTargetで設定したinputSchemaに合わせて型を定義
type GetJournalsEvent = {
  yearMonth: string;
};

// GatewayTargetで設定したoutputSchemaに合わせて型を定義
type GetJournalsResponse = {
  success: boolean;
  data: Journal[] | null;
  error: string | null;
};

// Lambdaに設定した環境変数から取得
const TARGET_NAME = process.env.TARGET_NAME;
const GET_JOURNALS_TOOL_NAME = process.env.GET_JOURNALS_TOOL_NAME;

export async function handler(
  event: GetJournalsEvent,
  context: any
): Promise<GetJournalsResponse> {
  try {
    const extracted = extractTargetAndToolName(context);
    if (!extracted) {
      return {
        success: false,
        data: null,
        error: 'Invalid invocation context',
      };
    }

    const { targetName, toolName } = extracted;
    if (targetName !== TARGET_NAME) {
      return {
        success: false,
        data: null,
        error: 'Unknown Target',
      };
    }

    if (toolName === GET_JOURNALS_TOOL_NAME) {
      const yearMonth = event.yearMonth;
      if (!yearMonth) {
        return {
          success: false,
          data: null,
          error: 'Year and month are required',
        };
      }
      return {
        success: true,
        data: testJournals,
        error: null,
      };
    } else {
      return {
        success: false,
        data: null,
        error: 'Unknown Tool',
      };
    }
  } catch (error) {
    console.error('Error', error);
    return {
      success: false,
      data: null,
      error: 'Internal server error',
    };
  }
}

Gatewayのターゲット名とツール名を取得する実装は以下の公式ドキュメントを参考にしております。
Understand how AgentCore Gateway tools are named

実装コード
lambda/agent/accounting/utils.ts
export type ExtractResult = {
  targetName: string;
  toolName: string;
};

export function extractTargetAndToolName(context: any): ExtractResult | null {
  const delimiter = '___';

  const originalToolName: unknown =
    context?.clientContext?.custom?.bedrockAgentCoreToolName;

  if (typeof originalToolName !== 'string') {
    return null;
  }

  const parts = originalToolName.split(delimiter);
  if (parts.length < 2 || !parts[0] || !parts[1]) {
    return null;
  }

  return { targetName: parts[0], toolName: parts[1] };
}

Lambdaを作成しているCDKのコードです。

実装コード
lib/agent/gateway/handler.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import * as logs from 'aws-cdk-lib/aws-logs';

import {
  getStage,
  isProd,
  getLambdaLogRetention,
  getRemovalPolicy,
} from '../../utils';

interface GatewayHandlerConstructProps {
  stageName: string;
  accountingTargetName: string;
  getJournalsToolName: string;
}

const ACCOUNTING_HANDLER_TIMEOUT = cdk.Duration.seconds(15);

export class GatewayHandlerConstruct extends Construct {
  public readonly accountingHandler: lambda.Function;

  constructor(
    scope: Construct,
    id: string,
    props: GatewayHandlerConstructProps
  ) {
    super(scope, id);

    const stage = getStage(props.stageName);
    const isProduction = isProd(props.stageName);

    this.accountingHandler = new lambdaNodejs.NodejsFunction(
      this,
      'AccountingHandler',
      {
        functionName: `${stage}-accounting`,
        runtime: lambda.Runtime.NODEJS_22_X,
        entry: 'lambda/agent/accounting/handler.ts',
        handler: 'handler',
        memorySize: isProduction ? 256 : 128,
        timeout: ACCOUNTING_HANDLER_TIMEOUT,
        logGroup: new logs.LogGroup(this, 'AccountingHandlerLogGroup', {
          logGroupName: `/aws/lambda/${stage}-accounting`,
          retention: getLambdaLogRetention(stage),
          removalPolicy: getRemovalPolicy(stage),
        }),
        environment: {
          TARGET_NAME: `${stage}-${props.accountingTargetName}`,
          GET_JOURNALS_TOOL_NAME: props.getJournalsToolName,
        },
      }
    );
  }
}

Gateway Role

lib/agent/gateway/role.ts
export class GatewayRoleConstruct extends Construct {
  public readonly role: iam.Role;

  constructor(scope: Construct, id: string, props: GatewayRoleConstructProps) {
    super(scope, id);

    const stage = getStage(props.stageName);
    const stack = cdk.Stack.of(this);
    const region = stack.region;
    const account = stack.account;

    this.role = new iam.Role(this, 'Role', {
      roleName: `${stage}-accounting-agent-gateway-role`,
      description: 'Role assumed by Accounting Agent Gateway',
      assumedBy: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com', {
        conditions: {
          StringEquals: {
            'aws:SourceAccount': account,
          },
          ArnLike: {
            'aws:SourceArn': `arn:aws:bedrock-agentcore:${region}:${account}:*`,
          },
        },
      }),
    });
  }
}

Gateway

用途

  • AgentCore Runtimeで稼働しているエージェントがLambdaをツールとして呼び出して仕訳データを取得

実装コード

lib/agent/gateway/gateway.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as bedrockagentcore from 'aws-cdk-lib/aws-bedrockagentcore';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as iam from 'aws-cdk-lib/aws-iam';

import { getStage, isProd } from '../../utils';

interface GatewayConstructProps {
  stageName: string;
  userPool: cognito.IUserPool;
  userPoolClient: cognito.IUserPoolClient;
  gatewayRole: iam.Role;
}

export class GatewayConstruct extends Construct {
  public readonly gateway: bedrockagentcore.CfnGateway;

  constructor(scope: Construct, id: string, props: GatewayConstructProps) {
    super(scope, id);

    const stage = getStage(props.stageName);
    const isProduction = isProd(props.stageName);
    const region = cdk.Stack.of(this).region;

    this.gateway = new bedrockagentcore.CfnGateway(this, 'Gateway', {
      name: `${stage}-accounting-agent-gateway`,
      description: 'Accounting Agent Gateway',
      protocolType: 'MCP',
      protocolConfiguration: {
        mcp: {
          searchType: 'SEMANTIC',
        },
      },
      // インバウンド認証(Gatewayを呼び出すエージェントの認証)
      authorizerType: 'CUSTOM_JWT',
      authorizerConfiguration: {
        customJwtAuthorizer: {
          discoveryUrl: `https://cognito-idp.${region}.amazonaws.com/${props.userPool.userPoolId}/.well-known/openid-configuration`,
          allowedClients: [props.userPoolClient.userPoolClientId],
        },
      },
      roleArn: props.gatewayRole.roleArn,
      exceptionLevel: isProduction ? undefined : 'DEBUG',
    });

    new cdk.CfnOutput(this, 'GatewayUrl', {
      value: this.gateway.attrGatewayUrl,
    });
  }
}

Gateway Target

AWS Lambda function targets
GatewayのTargetを作成し、Lambdaと紐付けます。

実装コード

lib/agent/gateway/target.ts
import { Construct } from 'constructs';
import * as bedrockagentcore from 'aws-cdk-lib/aws-bedrockagentcore';

import { getStage } from '../../utils';

interface GatewayTargetConstructProps {
  stageName: string;
  gatewayIdentifier: string;
  accountingHandlerArn: string;
  accountingTargetName: string;
  getJournalsToolName: string;
}

export class GatewayTargetConstruct extends Construct {
  constructor(
    scope: Construct,
    id: string,
    props: GatewayTargetConstructProps
  ) {
    super(scope, id);

    const stage = getStage(props.stageName);

    new bedrockagentcore.CfnGatewayTarget(this, 'AccountingTarget', {
      name: `${stage}-${props.accountingTargetName}`,
      description: 'Accounting Target',
      gatewayIdentifier: props.gatewayIdentifier,
      credentialProviderConfigurations: [
        {
          // アウトバウンド認証(Gatewayが呼び出すツールへの接続認証)
          credentialProviderType: 'GATEWAY_IAM_ROLE',
        },
      ],
      targetConfiguration: {
        mcp: {
          lambda: {
            lambdaArn: props.accountingHandlerArn,
            toolSchema: {
              inlinePayload: [
                {
                  name: props.getJournalsToolName,
                  description: 'Get journals by year and month',
                  inputSchema: {
                    type: 'object',
                    properties: {
                      yearMonth: {
                        type: 'string',
                        description: 'the year and month e.g. 2025-01',
                      },
                    },
                    required: ['yearMonth'],
                  },
                  outputSchema: {
                    type: 'object',
                    properties: {
                      success: {
                        type: 'boolean',
                        description: 'whether the result is successful or not',
                      },
                      data: {
                        type: 'array',
                        description: 'the result when success is true',
                        items: {
                          type: 'object',
                          properties: {
                            journal_id: { type: 'string' },
                            date: { type: 'string' },
                            division: { type: 'string' },
                            customer: { type: 'string' },
                            amount: { type: 'integer' },
                            description: { type: 'string' },
                          },
                          required: [
                            'journal_id',
                            'date',
                            'division',
                            'customer',
                            'amount',
                            'description',
                          ],
                        },
                      },
                      error: {
                        type: 'string',
                        description: 'error message when success is false',
                      },
                    },
                    required: ['success', 'data', 'error'],
                  },
                },
              ],
            },
          },
        },
      },
    });
  }
}

Gatewayのまとめ

Lambda関数、Gateway Role、Gateway、Gateway Targetをまとめたコード(Stack)です。

lib/agent/gateway/index.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as bedrockagentcore from 'aws-cdk-lib/aws-bedrockagentcore';
import * as cognito from 'aws-cdk-lib/aws-cognito';

import { GatewayHandlerConstruct } from './handler';
import { GatewayRoleConstruct } from './role';
import { GatewayConstruct } from './gateway';
import { GatewayTargetConstruct } from './target';

interface GatewayStackProps extends cdk.StackProps {
  stageName: string;
  userPool: cognito.IUserPool;
  userPoolClient: cognito.IUserPoolClient;
}

// Gateway Target and Tool Name
const ACCOUNTING_TARGET_NAME = 'accounting';
const GET_JOURNALS_TOOL_NAME = 'get_journals';

export class GatewayStack extends cdk.Stack {
  public readonly gateway: bedrockagentcore.CfnGateway;

  constructor(scope: Construct, id: string, props: GatewayStackProps) {
    super(scope, id, props);

    const gatewayHandler = new GatewayHandlerConstruct(this, 'GatewayHandler', {
      stageName: props.stageName,
      accountingTargetName: ACCOUNTING_TARGET_NAME,
      getJournalsToolName: GET_JOURNALS_TOOL_NAME,
    });

    const gatewayRole = new GatewayRoleConstruct(this, 'GatewayRole', {
      stageName: props.stageName,
    });
    // Gateway RoleにLambda Invoke権限を付与
    gatewayHandler.accountingHandler.grantInvoke(gatewayRole.role);

    this.gateway = new GatewayConstruct(this, 'Gateway', {
      stageName: props.stageName,
      userPool: props.userPool,
      userPoolClient: props.userPoolClient,
      gatewayRole: gatewayRole.role,
    }).gateway;
    this.gateway.node.addDependency(gatewayHandler);
    this.gateway.node.addDependency(gatewayRole);

    const gatewayTarget = new GatewayTargetConstruct(this, 'GatewayTarget', {
      stageName: props.stageName,
      gatewayIdentifier: this.gateway.attrGatewayIdentifier,
      accountingHandlerArn: gatewayHandler.accountingHandler.functionArn,
      accountingTargetName: ACCOUNTING_TARGET_NAME,
      getJournalsToolName: GET_JOURNALS_TOOL_NAME,
    });
    gatewayTarget.node.addDependency(this.gateway);
  }
}

AgentCore Runtime

公式ドキュメント

Amazon Bedrock AgentCore Runtime provides a secure, serverless and purpose-built hosting environment for deploying and running AI agents or tools.

AgentCore Runtimeは、AIエージェントやツールのデプロイおよび実行向けに、セキュリティとサーバーレス設計を最適化した専用のホスティング環境を提供します。

AgentCore Role

  • Execution role for running an agent in AgentCore Runtime
  • 以下の権限は会社のポリシーに合わせて限定することを推奨
  • 今回はJWT認証にCognito、セッションの保存にS3を利用しているため、公式ドキュメントの設定例に加えてCognito、S3の権限を設定しています
lib/agent/runtime/role.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as s3 from 'aws-cdk-lib/aws-s3';

import { getStage } from '../../utils';

interface RuntimeRoleConstructProps {
  stageName: string;
  runtimeName: string;
  ecrRepository: ecr.IRepository;
  userPoolId: string;
  bucket: s3.IBucket;
}

export class RuntimeRoleConstruct extends Construct {
  public readonly role: iam.Role;

  constructor(scope: Construct, id: string, props: RuntimeRoleConstructProps) {
    super(scope, id);

    const stage = getStage(props.stageName);
    const stack = cdk.Stack.of(this);
    const region = stack.region;
    const account = stack.account;

    this.role = new iam.Role(this, 'Role', {
      roleName: `${stage}-agentcore-runtime-role`,
      description: 'Role assumed by Bedrock AgentCore Runtime',
      assumedBy: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com', {
        conditions: {
          StringEquals: {
            'aws:SourceAccount': account,
          },
          ArnLike: {
            'aws:SourceArn': `arn:aws:bedrock-agentcore:${region}:${account}:*`,
          },
        },
      }),
    });

    // ECR
    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: ['ecr:BatchGetImage', 'ecr:GetDownloadUrlForLayer'],
        resources: [`arn:aws:ecr:${region}:${account}:repository/*`],
      })
    );

    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: ['ecr:GetAuthorizationToken'],
        resources: ['*'],
      })
    );

    // CloudWatch Logs
    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: ['logs:DescribeLogStreams', 'logs:CreateLogGroup'],
        resources: [
          `arn:aws:logs:${region}:${account}:log-group:/aws/bedrock-agentcore/runtimes/*`,
        ],
      })
    );

    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: ['logs:DescribeLogGroups'],
        resources: [`arn:aws:logs:${region}:${account}:log-group:*`],
      })
    );

    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: ['logs:CreateLogStream', 'logs:PutLogEvents'],
        resources: [
          `arn:aws:logs:${region}:${account}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*`,
        ],
      })
    );

    // CloudWatch Metrics
    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: ['cloudwatch:PutMetricData'],
        resources: ['*'],
        conditions: {
          StringEquals: { 'cloudwatch:namespace': 'bedrock-agentcore' },
        },
      })
    );

    // X-Ray
    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: [
          'xray:PutTraceSegments',
          'xray:PutTelemetryRecords',
          'xray:GetSamplingRules',
          'xray:GetSamplingTargets',
        ],
        resources: ['*'],
      })
    );

    // AgentCore Workload Access Token
    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: [
          'bedrock-agentcore:GetWorkloadAccessToken',
          'bedrock-agentcore:GetWorkloadAccessTokenForJWT',
          'bedrock-agentcore:GetWorkloadAccessTokenForUserId',
        ],
        resources: [
          `arn:aws:bedrock-agentcore:${region}:${account}:workload-identity-directory/default`,
          `arn:aws:bedrock-agentcore:${region}:${account}:workload-identity-directory/default/workload-identity/${props.runtimeName}-*`,
        ],
      })
    );

    // Bedrock
    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: [
          'bedrock:InvokeModel',
          'bedrock:InvokeModelWithResponseStream',
        ],
        resources: [
          'arn:aws:bedrock:*::foundation-model/*',
          `arn:aws:bedrock:${region}:${account}:*`,
        ],
      })
    );

    // Cognito
    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: ['cognito-idp:DescribeUserPoolClient'],
        resources: [
          `arn:aws:cognito-idp:${region}:${account}:userpool/${props.userPoolId}`,
        ],
      })
    );

    // S3 Bucket
    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: ['s3:ListBucket'],
        resources: [props.bucket.bucketArn],
      })
    );

    // S3 Objects
    this.role.addToPolicy(
      new iam.PolicyStatement({
        actions: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject'],
        resources: [`${props.bucket.bucketArn}/*`],
      })
    );
  }
}

Runtime

  • クライアントがエージェントを呼び出すための認証はデフォルトはIAM認証
  • 今回はWebアプリからの呼びやすさを考慮してJWT認証で実装しています
lib/agent/runtime/runtime.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as bedrockagentcore from 'aws-cdk-lib/aws-bedrockagentcore';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';

interface RuntimeConstructProps {
  stageName: string;
  runtimeName: string;
  runtimeRole: iam.Role;
  ecrRepository: ecr.IRepository;
  imageTag: string;
  userPool: cognito.IUserPool;
  userPoolDomain: cognito.UserPoolDomain;
  // クライアントがエージェントを呼び出すためのクライアント
  userPoolClientForUser: cognito.IUserPoolClient;
  // エージェントがGatewayへ接続するためのクライアント
  userPoolClientForAgent: cognito.IUserPoolClient;
  gatewayUrl: string;
  bucket: s3.IBucket;
}

export class RuntimeConstruct extends Construct {
  public readonly runtime: bedrockagentcore.CfnRuntime;

  constructor(scope: Construct, id: string, props: RuntimeConstructProps) {
    super(scope, id);

    const region = cdk.Stack.of(this).region;

    this.runtime = new bedrockagentcore.CfnRuntime(this, 'Runtime', {
      agentRuntimeName: props.runtimeName,
      description: 'Accounting Agent Runtime',
      agentRuntimeArtifact: {
        containerConfiguration: {
          containerUri: `${props.ecrRepository.repositoryUri}:${props.imageTag}`,
        },
      },
      networkConfiguration: {
        networkMode: 'PUBLIC',
      },
      protocolConfiguration: 'HTTP',
      // インバウンド認証(エージェントを呼び出すクライアントを認証)
      authorizerConfiguration: {
        customJwtAuthorizer: {
          discoveryUrl: `https://cognito-idp.${region}.amazonaws.com/${props.userPool.userPoolId}/.well-known/openid-configuration`,
          // アクセストークンを受け取る想定
          allowedClients: [props.userPoolClientForUser.userPoolClientId],
        },
      },
      roleArn: props.runtimeRole.roleArn,
      // エージェントで利用する環境変数
      environmentVariables: {
        AWS_REGION: region,
        GATEWAY_URL: props.gatewayUrl,
        USER_POOL_ID: props.userPool.userPoolId,
        USER_POOL_CLIENT_ID: props.userPoolClientForAgent.userPoolClientId,
        COGNITO_TOKEN_URL: `https://${props.userPoolDomain.domainName}.auth.${region}.amazoncognito.com/oauth2/token`,
        BUCKET_NAME: props.bucket.bucketName,
      },
    });

    new cdk.CfnOutput(this, 'RuntimeArn', {
      value: this.runtime.attrAgentRuntimeArn,
    });
  }
}

Runtimeのまとめ

Runtime Role、Runtimeをまとめたコード(Stack)です。

lib/agent/runtime/index.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as bedrockagentcore from 'aws-cdk-lib/aws-bedrockagentcore';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as s3 from 'aws-cdk-lib/aws-s3';

import { getStage } from '../../utils';
import { RuntimeRoleConstruct } from './role';
import { RuntimeConstruct } from './runtime';

interface RuntimeStackProps extends cdk.StackProps {
  stageName: string;
  ecrRepository: ecr.IRepository;
  imageTag: string;
  userPool: cognito.IUserPool;
  userPoolDomain: cognito.UserPoolDomain;
  // クライアントがエージェントを呼び出すためのクライアント
  userPoolClientForUser: cognito.IUserPoolClient;
  // エージェントがGatewayへ接続するためのクライアント
  userPoolClientForAgent: cognito.IUserPoolClient;
  // AgentCore GatewayのURL
  gatewayUrl: string;
  bucket: s3.IBucket;
}

export class RuntimeStack extends cdk.Stack {
  public readonly runtime: bedrockagentcore.CfnRuntime;

  constructor(scope: Construct, id: string, props: RuntimeStackProps) {
    super(scope, id, props);

    const stage = getStage(props.stageName);
    const runtimeName = `${stage}_accounting_agent`;

    const runtimeRole = new RuntimeRoleConstruct(this, 'RuntimeRole', {
      stageName: props.stageName,
      runtimeName: runtimeName,
      ecrRepository: props.ecrRepository,
      userPoolId: props.userPool.userPoolId,
      bucket: props.bucket,
    });

    this.runtime = new RuntimeConstruct(this, 'Runtime', {
      stageName: props.stageName,
      runtimeName: runtimeName,
      runtimeRole: runtimeRole.role,
      ecrRepository: props.ecrRepository,
      imageTag: props.imageTag,
      userPool: props.userPool,
      userPoolDomain: props.userPoolDomain,
      userPoolClientForUser: props.userPoolClientForUser,
      userPoolClientForAgent: props.userPoolClientForAgent,
      gatewayUrl: props.gatewayUrl,
      bucket: props.bucket,
    }).runtime;

    this.runtime.node.addDependency(runtimeRole);
  }
}

エージェントの実装

Deploying Strands Agents to Amazon Bedrock AgentCore RuntimeのCustom Agent(Option B)に基づき実装しております。

Dockerfile

FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
WORKDIR /app

# Copy uv files
COPY pyproject.toml uv.lock ./

# Install dependencies (including strands-agents)
RUN uv sync --frozen --no-cache --no-dev

# Copy source code
COPY src/ ./src/

# Expose port
EXPOSE 8080

# Run application
CMD ["uv", "run", "opentelemetry-instrument", "uvicorn", "agent.main:app", "--app-dir", "src", "--host", "0.0.0.0", "--port", "8080"]

main.py

以降ではコメント箇所の以下の論点について記載します。

  1. セッション管理
  2. MCPツールの取得
  3. エージェント
src/agent/main.py
import logging

from datetime import datetime, timezone
from typing import Annotated, Any, Dict

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field

from agent.agent_runner import run_agent
from agent.client_secret import get_client_secret
from agent.gateway import (
    create_mcp_client,
    get_full_tools_list,
)
from agent.settings import settings
from agent.token import get_access_token

app = FastAPI(title="Strands Agent Server", version="1.0.0")


class InvocationRequest(BaseModel):
    prompt: str = Field(description="user prompt", min_length=1, max_length=1000)


class InvocationResponse(BaseModel):
    output: Dict[str, Any]


@app.post("/invocations", response_model=InvocationResponse)
async def invoke_agent(
    request: InvocationRequest,
    x_amzn_bedrock_agentcore_runtime_session_id: Annotated[str, Header()],
):
    # 1. セッション管理
    session_id = x_amzn_bedrock_agentcore_runtime_session_id
    if not session_id:
        raise HTTPException(status_code=400, detail="Session ID is required")

    user_prompt = request.prompt
    if not user_prompt:
        raise HTTPException(status_code=400, detail="Prompt is required")

    try:
        # 2. MCPツールの取得
        client_secret = get_client_secret()
        access_token = get_access_token(
            settings.user_pool_client_id,
            client_secret,
            settings.cognito_token_url,
        )
        mcp_client = create_mcp_client(settings.gateway_url, access_token)
        # StrandsでMCPツールを使用する場合、エージェントの操作はMCPクライアントのコンテキストマネージャー内で実行
        with mcp_client:
            tools = get_full_tools_list(mcp_client)
            # 3. エージェント
            result = run_agent(session_id, tools, user_prompt)
            response = {
                "message": result,
                "timestamp": datetime.now(timezone.utc).isoformat(),
            }
            return InvocationResponse(output=response)

    except Exception as e:
        logging.error(f"Agent processing failed: {str(e)}")
        raise HTTPException(status_code=500, detail="Agent processing failed")


@app.get("/ping")
async def ping():
    return {"status": "healthy"}


if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="0.0.0.0", port=8080)

セッション管理

AgentCore: Use isolated sessions for agents
Strands Agent: Session Management

AgentCore Runtimeは、ユーザーセッションを分離し、ユーザーセッション内で複数回の呼び出しにわたって安全にコンテキストを再利用できます。
セッションの永続化は、今回は長期記憶の要件がないため、Strands AgentsのSession Managementを採用しています。

まず、HTTPヘッダーからX-Amzn-Bedrock-AgentCore-Runtime-Session-Idを取得します。

async def invoke_agent(
    request: InvocationRequest,
    x_amzn_bedrock_agentcore_runtime_session_id: Annotated[str, Header()],
):
    # 1. セッション管理
    session_id = x_amzn_bedrock_agentcore_runtime_session_id

次に、Agentをインスタンス化する際にsession_managerを渡すことで、会話履歴を永続化します(今回は会話履歴を保存するS3でlifecycleRulesを設定しています。)。

from strands.session.s3_session_manager import S3SessionManager

session_manager = S3SessionManager(
    region_name=settings.aws_region,
    bucket=settings.bucket_name,
    session_id=session_id,
)

agent = Agent(
    session_manager=session_manager,
)

補足

今回は実装しておりませんが、request header allowlistを設定することでJWTトークンをAgentCore Runtimeで利用することができます(公式ドキュメント)。
JWTトークンのカスタムクレームに設定しているテナントIDなどを取得する際に便利です。

MCPツールの取得

AgentCore Gatewayで定義したMCPツール(Lambda)を以下の手順で取得します。

  • Cognitoのクライアントシークレットの取得
  • クライアントクレデンシャルフローでアクセストークンを取得
  • MCPクライアントを生成
  • AgentCore GatewayのTargetに紐付けたツール(Lambda)を取得
# 2. MCPツールの取得
client_secret = get_client_secret()
access_token = get_access_token(
    settings.user_pool_client_id,
    client_secret,
    settings.cognito_token_url,
)
mcp_client = create_mcp_client(settings.gateway_url, access_token)
    # StrandsでMCPツールを使用する場合、エージェントの操作はMCPクライアントのコンテキストマネージャー内で実行
    with mcp_client:
        tools = get_full_tools_list(mcp_client)

エージェント

セッションID、Gatewayから取得したMCPツール、ユーザープロンプトを渡してエージェントを生成します。

# 3. エージェント
result = run_agent(session_id, tools, user_prompt)

エージェントの中身

run_agent()で呼び出すAIエージェントは、ユーザーのプロンプトに応じて実行するワークフローを決定しています。

仕訳レビューのワークフローは対象の経理業務③に対応しています。
仕訳分析のワークフローは対象の経理業務④に対応しています。

以下がrun_agent()の具体的なコードです(実際のコードを一部加工しております)。
エージェント(ワークフロー)をツールとして指定しています。
Strands AgentsのAgents as Toolsがベースです。

from strands import Agent, tool
from strands.tools.mcp.mcp_agent_tool import MCPAgentTool

from agent.agent_settings import (
    bedrock_model,
    conversation_manager,
    create_s3_session_manager,
)
from agent.gateway import (
    filter_tools,
    get_journal_tool_names,
)
from agent.journal_agent import (
    analyze_customer_sales_workflow,
    investigate_division_workflow,
)


def run_agent(
    session_id: str, gateway_tools: list[MCPAgentTool], user_prompt: str
) -> str:
    """
    Create an Agent from a list of MCP tools.
    """

    @tool
    def journal_investigate_division_workflow(query: str):
        """
        会計システムから指定した月とその直前の月の仕訳データを取得し、部門(division)の登録に誤りがないかを調査するツール

        利用タイミング:
        - 対象の年月における部門(division)の設定が正しいか検証したいとき

        入力:
        - query: 対象年月を含む自然言語
          例: "2025年2月の仕訳のdivisionの登録が正しいか調査してください"

        出力:
        - 次の形式の JSON を返してください
          {
            "overview": "調査結果の概要",
            "division_investigations": [
              {
                "customer": "顧客名",
                "target_month_journal_id": "対象の年月の仕訳ID",
                "target_month_division": "対象の年月のdivision",
                "previous_month_journal_id": "直前の年月の仕訳ID",
                "previous_month_division": "直前の年月のdivision"
              }
            ]
          }

        留意点:
        - 対象月と直前月のみを比較対象とします
        """
        journal_tools = filter_tools(gateway_tools, get_journal_tool_names())
        try:
            result = investigate_division_workflow(query, journal_tools)
            return result
        except Exception as e:
            return f"Error in journal assistant: {str(e)}"

    @tool
    def journal_analyze_customer_sales_workflow(query: str):
        """
        会計システムから指定した月とその直前の月の仕訳データを取得し、顧客別の売上金額を比較するツール

        利用タイミング:
        - 対象の年月における顧客別の売上金額の変動を分析したいとき

        入力:
        - query: 対象年月を含む自然言語
          例: "2025年2月の顧客別の売上金額を比較してください"

        出力:
        - 次の形式の JSON を返してください
          {
            "overview": "比較結果の概要",
            "customer_sales_amounts": [
              {
                "customer": "顧客名",
                "target_month_sales_amount": "対象の年月の売上金額",
                "previous_month_sales_amount": "対象の年月の直前の年月の売上金額",
                "difference": "対象の年月と直前の年月の売上金額の差額"
              }
            ]
          }

        留意点:
        - 対象月と直前月のみを比較対象とします
        """
        journal_tools = filter_tools(gateway_tools, get_journal_tool_names())
        try:
            result = analyze_customer_sales_workflow(query, journal_tools)
            return result
        except Exception as e:
            return f"Error in journal customer sales assistant: {str(e)}"

    session_manager = create_s3_session_manager(session_id)

    agent = Agent(
        agent_id="agent_runner",
        model=bedrock_model,
        tools=[
            journal_investigate_division_workflow,
            journal_analyze_customer_sales_workflow,
        ],
        session_manager=session_manager,
        conversation_manager=conversation_manager,
    )

    result = agent(user_prompt)
    return result.message["content"][0].get("text", "")

ワークフロー

経理業務では「特定の順序で実行する業務」が多くあるため、業務ごとにワークフローを実装しています。
ワークフローの実装はStrands AgentsのAgent Workflowsをベースにしています。
経理エージェントの規模が大きくなったタイミングで、Graph Multi-Agent Patternの検討を考えております。

以下がワークフローの具体的なコードです(実際のコードを一部加工しております)。

import logging

from pydantic import BaseModel, Field, ValidationError
from strands import Agent
from strands.tools.mcp.mcp_agent_tool import MCPAgentTool

from agent.agent_settings import (
    bedrock_model,
    conversation_manager,
)


class TargetYearMonth(BaseModel):
    target_year_month: str = Field(description="対象の年月")
    previous_year_month: str = Field(description="対象の年月の直前の年月")


class DivisionInvestigation(BaseModel):
    customer: str = Field(description="顧客名")
    target_month_journal_id: str = Field(description="対象の年月の仕訳ID")
    target_month_division: str = Field(description="対象の年月のdivision")
    previous_month_journal_id: str = Field(description="対象の年月の直前の年月の仕訳ID")
    previous_month_division: str = Field(description="対象の年月の直前の年月のdivision")


class DivisionInvestigationList(BaseModel):
    overview: str = Field(description="調査結果の概要")
    division_investigations: list[DivisionInvestigation] = Field(
        description="顧客ごとのdivisionの調査結果"
    )


class CustomerSalesAnalysis(BaseModel):
    customer: str = Field(description="顧客名")
    target_month_sales_amount: int = Field(description="対象の年月の売上金額")
    previous_month_sales_amount: int = Field(
        description="対象の年月の直前の年月の売上金額"
    )
    difference: int = Field(description="対象の年月と直前の年月の売上金額の差額")


class CustomerSalesAnalysesList(BaseModel):
    overview: str = Field(description="分析結果の概要")
    customer_sales_amounts: list[CustomerSalesAnalysis] = Field(
        description="顧客ごとの売上金額の対象の年月と直前の年月の比較結果"
    )


def decide_year_month() -> Agent:
    return Agent(
        agent_id="decide_year_month",
        model=bedrock_model,
        conversation_manager=conversation_manager,
        system_prompt="""
        あなたは年月を抽出するエージェントです。
        与えられたテキストから年月を抽出してください。

        # 抽出する年月
        1. テキストに入力されている年月
        2. 1の年月の直前の月の年月

        # 出力形式
        {
            "target_year_month": "2025-02",
            "previous_year_month": "2025-01"
        }
    """,
    )


def get_journals(journal_tools: list[MCPAgentTool]) -> Agent:
    return Agent(
        agent_id="get_journals",
        model=bedrock_model,
        tools=journal_tools,
        conversation_manager=conversation_manager,
        system_prompt="""
    あなたは会計ソフトから仕訳を取得するエージェントです。
    get_journalsツールを使用して指定の年月の仕訳を取得してください。
    """,
    )


def investigate_division() -> Agent:
    return Agent(
        agent_id="investigate_division",
        model=bedrock_model,
        conversation_manager=conversation_manager,
        system_prompt="""
    あなたは仕訳の部門(division)の入力誤りを調査するエージェントです。

    # 調査対象
    1. 対象月の仕訳
    2. 対象月の直前の月の仕訳

    # 調査方法
    1. 取引先ごとに対象月と直前の月の部門(division)の値を比較
    2. 対象月と直前の月の部門(division)の値が異なる場合、調査結果に追加

    # 調査結果の形式
    {
        "overview": "調査結果の概要",
        "division_investigations": [
            {
                "customer": "顧客名",
                "target_month_journal_id": "対象の年月の仕訳ID",
                "target_month_division": "対象の年月のdivision",
                "previous_month_journal_id": "対象の年月の直前の年月の仕訳ID",
                "previous_month_division": "対象の年月の直前の年月のdivision"
            }
        ]
    }
    """,
    )


def analyze_customer_sales() -> Agent:
    return Agent(
        agent_id="analyze_customer_sales",
        model=bedrock_model,
        conversation_manager=conversation_manager,
        system_prompt="""
        あなたは仕訳の顧客別の売上金額を比較するエージェントです。
        与えられた仕訳から顧客別の売上金額を比較してください。

        # 比較対象
        1. 対象の年月の仕訳
        2. 対象の年月の直前の年月の仕訳

        # 比較方法
        1. 顧客ごとに対象の年月と直前の年月の売上金額を比較

        # 出力形式
        {
            "overview": "比較結果の概要",
            "customer_sales_amounts": [
                {
                    "customer": "顧客名",
                    "target_month_sales_amount": "対象の年月の売上金額",
                    "previous_month_sales_amount": "対象の年月の直前の年月の売上金額",
                    "difference": "対象の年月と直前の年月の売上金額の差額"
                }
            ]
        }
    """,
    )


def investigate_division_workflow(query: str, journal_tools: list[MCPAgentTool]):
    decide_year_month_agent = decide_year_month()
    year_month = decide_year_month_agent.structured_output(TargetYearMonth, query)

    get_journals_agent = get_journals(journal_tools)
    target_journals = get_journals_agent(year_month.target_year_month)
    previous_journals = get_journals_agent(year_month.previous_year_month)

    investigate_division_agent = investigate_division()

    try:
        result = investigate_division_agent.structured_output(
            DivisionInvestigationList,
            f"""
            以下の仕訳を調査してください。
            対象の年月の仕訳: {target_journals}
            対象の年月の直前の年月の仕訳: {previous_journals}
            """,
        )
        return result.model_dump()
    except ValidationError as e:
        logging.error(f"ValidationError: {e}")
        return None


def analyze_customer_sales_workflow(query: str, journal_tools: list[MCPAgentTool]):
    decide_year_month_agent = decide_year_month()
    year_month = decide_year_month_agent.structured_output(TargetYearMonth, query)

    get_journals_agent = get_journals(journal_tools)
    target_journals = get_journals_agent(year_month.target_year_month)
    previous_journals = get_journals_agent(year_month.previous_year_month)

    analyze_customer_sales_agent = analyze_customer_sales()

    try:
        result = analyze_customer_sales_agent.structured_output(
            CustomerSalesAnalysesList,
            f"""
            以下の仕訳の顧客別の売上金額を比較してください。
            対象の年月の仕訳: {target_journals}
            対象の年月の直前の年月の仕訳: {previous_journals}
            """,
        )
        return result.model_dump()
    except ValidationError as e:
        logging.error(f"ValidationError: {e}")
        return None

エージェントの動作確認

仕訳の登録内容をレビュー

同じ取引先に対して、対象月が前月と同じ部門を登録しているか(部門入力の正確性、継続性)をレビューします。
従来は個別の仕訳レビュー時の目視確認、会計システムで検索、スプレッドシートで分析等の方法で仕訳登録の正確性、継続性を担保していましたが、AIエージェントのサポートを受けることで効率化ができました!

@pytest.mark.journal_agent
def test_investigate_division_workflow():
    client_secret = get_client_secret()
    access_token = get_access_token(
        settings.user_pool_client_id, client_secret, settings.cognito_token_url
    )
    mcp_client = create_mcp_client(settings.gateway_url, access_token)
    with mcp_client:
        tools = get_full_tools_list(mcp_client)
        journal_tools = filter_tools(tools, get_journal_tool_names())
        result = investigate_division_workflow(
            "2025年2月の仕訳について、部門(division)の登録が正しいか調査してください。", journal_tools
        )
        print(json.dumps(result, indent=2, ensure_ascii=False))

実行結果

{
  "overview": "2025年1月と2025年2月の仕訳データを比較した結果、顧客Aの部門(division)に変更が見られました。顧客Aは2025年1月ではセールスA部門でしたが、2025年2月ではセールスB部門に変更されています。顧客B、顧客Cについては部門の変更はありませんでした。",
  "division_investigations": [
    {
      "customer": "顧客A",
      "target_month_journal_id": "04",
      "target_month_division": "セールスB",
      "previous_month_journal_id": "01",
      "previous_month_division": "セールスA"
    }
  ]
}

取引先別の売上高の前月比

取引先別の売上高の前月比を分析します。
実際の業務では分析結果をもとに月次の業績報告レポートを作成しています!

@pytest.mark.journal_agent
def test_analyze_customer_sales_workflow():
    client_secret = get_client_secret()
    access_token = get_access_token(
        settings.user_pool_client_id, client_secret, settings.cognito_token_url
    )
    mcp_client = create_mcp_client(settings.gateway_url, access_token)
    with mcp_client:
        tools = get_full_tools_list(mcp_client)
        journal_tools = filter_tools(tools, get_journal_tool_names())
        result = analyze_customer_sales_workflow(
            "2025年2月の仕訳について、取引先別の売上高の前月比を分析してください。", journal_tools
        )
        print(json.dumps(result, indent=2, ensure_ascii=False))

実行結果

{
  "overview": "2025年2月と2025年1月の顧客別売上金額を比較しました。全体的な売上は1,000円増加(6,000円→7,000円)しています。顧客Aは変化なし(1,000円)、顧客Bも変化なし(2,000円)でしたが、顧客Cは1,000円増加(3,000円→4,000円)しており、全体の売上増加は顧客Cの売上増によるものです。",
  "customer_sales_amounts": [
    {
      "customer": "顧客A",
      "target_month_sales_amount": 1000,
      "previous_month_sales_amount": 1000,
      "difference": 0
    },
    {
      "customer": "顧客B",
      "target_month_sales_amount": 2000,
      "previous_month_sales_amount": 2000,
      "difference": 0
    },
    {
      "customer": "顧客C",
      "target_month_sales_amount": 4000,
      "previous_month_sales_amount": 3000,
      "difference": 1000
    }
  ]

補足

本記事では1ヶ月分のすべての仕訳を取得する例を実装しております。
実際には入出力のトークン数の制約、分析精度の向上の観点から、仕訳は勘定科目などで絞って取得しています。

おわりに

最後までお読みいただき、ありがとうございました!
コードの誤り、解釈の誤りなどがございましたらご指摘いただけますと幸いです。

参考文献

株式会社Grooves

Discussion