🚀

Amazon Bedrock Inline AgentでPersonalized AI Agentを素早く実現&改善する

に公開

Amazon Bedrock Inline Agent とは

これです。
https://aws.amazon.com/jp/about-aws/whats-new/2024/11/inlineagents-agents-amazon-bedrock/

手っ取り早く分かりやすい特徴はコレ。

ユーザーは事前設定されたコントロールプレーン設定に頼ることなく、基盤モデル、命令、アクショングループ、ガードレール、ナレッジベースをその場で指定できるようになります。

要するに、AWSのマネジメントコンソールとかCLIとか使って事前に定義を作成したり、試行錯誤の度に定義を更新したりデプロイしたりする必要が無い、ということです。ローカル環境にあるソースコードを書き換えたら、ローカル環境で実行するだけで変更の結果が確認できる、というわけです。

この一つ目の特徴は多くの場所で触れられているので今回はフォーカスせずに、もう一つの重要な特徴にフォーカスしていこうと思います。

エージェント機能の柔軟性と制御性が向上します。

つまり以下のようなことが可能なので、お手軽にユーザー毎にカスタマイズされたエージェント、すなわち"Personalized AI Agent"を実現することができます。

  • 認証の有無によってエージェントが利用可能なツールを変更する
  • ユーザーの権限によってエージェントが利用する基盤モデルを変更する

Return Control で安全なツール実行

これです。
https://aws.amazon.com/jp/about-aws/whats-new/2024/04/amazon-bedrock-agents-agent-creation-return-control-capability/

簡潔に言うと、こんな機能です。

開発者はアクションスキーマを定義し、エージェントがアクションを呼び出すたびにコントロールを取り戻すことができます。

少しわかりづらいので絵で説明します。

  1. クライアントはユーザーからのプロンプトをエージェントに送信する。
  2. エージェントはクライアントにツール実行要求で応答する。
  3. クライアントはツール実行要求に従ってツールを実行する。
  4. ツールは実行結果をクライアントへ返す。
  5. クライアントはツールの実行結果を再びエージェントに送る。
  6. エージェントからツール実行要求が応答されなくなるまで、2-5を繰り返す。
  7. 最終的な回答をユーザーに返す。

エージェントのツール実行要求はクライアントに戻されるのが最大の特徴です。エージェントがツール実行要求を構築する際に必要な情報を最低限にすることが出来ます。例えば、ユーザーのログイン情報やアクセストークンを要求する実装をツールとしてエージェントに提供するとしても、エージェントにはそれらのパラメータが必要であることを隠蔽し、クライアントが実行要求を受け取ってツールを実行する際に補完するようなことも可能です。つまり、トークンやパスワードなど隠しておきたい情報をエージェントにプロンプト等に含めて渡す必要が無い、ということです。

また、クライアントがツールを実行する方法は非常に自由です。MCP(Model Context Protocol)では、HTTP通信や標準入力などプロセス間通信が前提となっていますが、Return Controlの場合は単なるメソッド呼び出しとすることも可能になっています。これはクライアントを実行して結果を確認する際に依存関係のある別プロセスを起動させるなどの複雑な仕組みが不要であることを意味しています。この特徴は、Inline Agentと並んでエージェント開発における試行錯誤の高速化に大きく貢献してくれる存在です。

その他にも、Return Controlはクライアントとエージェントの対話が同一スレッド内で実行されるのでRDBトランザクションの境界内で実行することが出来る、など様々な可能性を秘めた仕組みとなっています。

Javaによるクライアント実装例

ここからは、Inline Agentと対話しながら最終的な応答を取得する実装を見ながら具体的な話をしていこうと思います。Javaを選んだ理由は、エンタープライズ領域でのバックエンド処理の自動化にエージェントを利用できるようにしたいというのと、Springframeworkと統合することで高い生産性を実現したいという2点です。

まずは以下にソースコード全体を示しておきます。ただし、200行を超えるサイズなの折りたたんでおきます。重要な部分はこの後に抜粋しながら触れていくことにします。

Java実装例
SimpleInlineAgentService
package com.mahitotsu.tsukumogami.apl.service;

import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.LinkedList;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.SequencedCollection;
import java.util.UUID;

import org.graalvm.polyglot.Context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

import software.amazon.awssdk.services.bedrockagentruntime.BedrockAgentRuntimeAsyncClient;
import software.amazon.awssdk.services.bedrockagentruntime.model.ActionGroupExecutor;
import software.amazon.awssdk.services.bedrockagentruntime.model.AgentActionGroup;
import software.amazon.awssdk.services.bedrockagentruntime.model.ContentBody;
import software.amazon.awssdk.services.bedrockagentruntime.model.CustomControlMethod;
import software.amazon.awssdk.services.bedrockagentruntime.model.FunctionDefinition;
import software.amazon.awssdk.services.bedrockagentruntime.model.FunctionInvocationInput;
import software.amazon.awssdk.services.bedrockagentruntime.model.FunctionResult;
import software.amazon.awssdk.services.bedrockagentruntime.model.FunctionSchema;
import software.amazon.awssdk.services.bedrockagentruntime.model.InlineAgentReturnControlPayload;
import software.amazon.awssdk.services.bedrockagentruntime.model.InlineSessionState;
import software.amazon.awssdk.services.bedrockagentruntime.model.InvocationInputMember;
import software.amazon.awssdk.services.bedrockagentruntime.model.InvocationResultMember;
import software.amazon.awssdk.services.bedrockagentruntime.model.InvokeInlineAgentRequest;
import software.amazon.awssdk.services.bedrockagentruntime.model.InvokeInlineAgentResponseHandler;
import software.amazon.awssdk.services.bedrockagentruntime.model.InvokeInlineAgentResponseHandler.Visitor;
import software.amazon.awssdk.services.bedrockagentruntime.model.ParameterDetail;
import software.amazon.awssdk.services.bedrockagentruntime.model.ParameterType;

/**
 * Amazon BedrockのInlineAgentを用いてユーザーからの要求を処理して応答を返すサービス。
 */
@Service
public class SimpleInlineAgentService {

    @Autowired
    private BedrockAgentRuntimeAsyncClient bedrockAgentRuntimeAsyncClient;

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * ユーザーからの入力をエージェントに引き渡し、最終的に返された応答を取得する。
     * 
     * @param prompt ユーザーからの入力
     * @return エージェントからの最終的な応答
     */
    public String execute(final String prompt) {

        final String sessionId = UUID.randomUUID().toString();
        final StringBuilder output = new StringBuilder();

        final Deque<InlineSessionState> sessionState = new LinkedList<>();
        final Deque<InlineAgentReturnControlPayload> returnControls = new LinkedList<>();
        final Collection<InvocationResultMember> results = new ArrayList<>();

        // エージェントからのツール実行要求が無くなるまでエージェントとのコミュニケーションを継続する
        // ツール実行要求が無くなった時点で最終的な応答が返されるので、その値を取得する
        do {
            final Collection<AgentActionGroup> actionGroups = new ArrayList<>();
            this.registerAgentActionGroups(actionGroups); // エージェントが利用可能なツール類を登録

            // 前回の実行要求をクリアしてからエージェントの実行要求を送信
            // 応答を受信する過程で、エージェントからのツール実行要求や最終的な応答の取得を行う
            returnControls.clear();
            this.bedrockAgentRuntimeAsyncClient.invokeInlineAgent(
                    this.buildInvokeInlineAgentRequest(sessionId, prompt,
                            sessionState.size() > 0 ? sessionState.pop() : null, // セッション属性がある場合のみセッションを指定
                            actionGroups),
                    this.buildInvokeInlineAgentResponseHandler(output, returnControls))
                    .join();

            // 前回の実行結果をクリアしてから受信したツール実行要求を処理
            results.clear();
            for (final InlineAgentReturnControlPayload payload : returnControls) {
                this.logger.info("process the payload: " + payload);
                // ツール実行要求を処理した結果を登録する
                results.addAll(this.buildInvocationResultMembers(payload));
            }

            // ツール実行結果が存在する場合、セッションの属性として登録する
            // セッションの属性は、次回のエージェント実行要求に含めてエージェントに送信される
            if (results.size() > 0) {
                sessionState.push(InlineSessionState.builder()
                        .invocationId(returnControls.peek().invocationId())
                        .returnControlInvocationResults(results)
                        .build());
                this.logger.info("the next session state: " + sessionState.peek());
            }

        } while (returnControls.size() > 0);

        // 最終的な応答を返却する
        this.logger.info("Result: " + output.toString());
        return output.toString();
    }

    /**
     * インラインエージェントの実行要求を構築して返す。
     * 
     * @param sessionId    一連のリクエストを識別するためのセッションID
     * @param inputText    エージェントに引き渡すユーザーからの入力値
     * @param sessionState 一連のリクエストで共有されるセッションの属性
     * @param actionGroups エージェントが利用可能なツールの定義
     * @return インラインエージェントの実行要求
     */
    private InvokeInlineAgentRequest buildInvokeInlineAgentRequest(final String sessionId, final String inputText,
            final InlineSessionState sessionState, final Collection<AgentActionGroup> actionGroups) {

        // 認証済みユーザーの権限に応じて利用する基盤モデルを切り替える
        final Collection<String> roles = new ArrayList<>();
        Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
                .ifPresent(a -> a.getAuthorities().stream().forEach(b -> roles.add(b.getAuthority().toUpperCase())));
        final String foundationModel = roles.contains("ROLE_PREMIUM") ? "apac.anthropic.claude-3-5-sonnet-20241022-v2:0"
                : "apac.amazon.nova-micro-v1:0";
        this.logger.info("Selected foundation model: " + foundationModel);

        // 実行要求を組み立てる
        return InvokeInlineAgentRequest.builder()
                .foundationModel(foundationModel)
                .instruction("""
                        あなたは以下の手順にしたがってユーザーの入力に対する応答を作成します。
                        * ユーザーが要求する内容を理解します。
                        * 要求された内容を達成するための作業項目を定義し、作業の実行計画を作成します。
                        * 作業項目ごとに必要となるツールを選定します。
                        * 選定したツールを利用しながら作業を実行し、最終的な応答を作成します。
                        """)
                .sessionId(sessionId)
                .inputText(sessionState == null ? inputText : null) // セッションに属性がある場合はプロンプトは無視されるので渡さない
                .inlineSessionState(sessionState)
                .actionGroups(actionGroups)
                .enableTrace(true)
                .build();
    }

    /**
     * エージェントの応答を処理するハンドラを構築して返す。
     * 
     * @param outputText     エージェントからの応答を格納するオブジェクト
     * @param returnControls エージェントからの実行要求を格納するオブジェクト
     * @return 応答を処理するハンドラ
     */
    private InvokeInlineAgentResponseHandler buildInvokeInlineAgentResponseHandler(final StringBuilder outputText,
            final SequencedCollection<InlineAgentReturnControlPayload> returnControls) {

        // onChunk -> 最終的な応答の断片を受け取ったら追記していく
        // onTrace -> Java SDKの場合、trace情報はマスクされてしまって何も情報が得られないので無視する
        // onReturnControl -> ツール実行要求を受け取った場合は処理対象として保持する
        return InvokeInlineAgentResponseHandler.builder()
                .onEventStream(publisher -> publisher.subscribe(event -> event.accept(Visitor.builder()
                        .onChunk(c -> outputText.append(c.bytes().asString(Charset.defaultCharset())))
                        // .onTrace(t -> this.logger.info(t.toString()))
                        .onReturnControl(rc -> returnControls.add(rc))
                        .build())))
                .build();
    }

    /**
     * 既定のアクショングループを登録する。
     * 
     * @param actionGroups アクショングループの登録先
     */
    private void registerAgentActionGroups(final Collection<AgentActionGroup> actionGroups) {

        // 認証済みではない場合、エージェントに対してツールを提供しない
        if (!Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()).map(a -> a.isAuthenticated())
                .orElse(false)) {
            return;
        }

        // ActionGroup定義を作成する
        // ActionGroup -> FunctionSchema -> ParameterDetail の構造になっている
        // description属性をLLMが解釈して、エージェントがツール実行要求を構築するので明確な内容を記載しておく
        final AgentActionGroup jsexecutor = AgentActionGroup.builder()
                .actionGroupName("CodeInterpreterAction")
                .actionGroupExecutor(ActionGroupExecutor.fromCustomControl(CustomControlMethod.RETURN_CONTROL))
                .description("""
                        javascriptのコードを実行することが出来るツールです。
                        """)
                .functionSchema(FunctionSchema.builder().functions(
                        FunctionDefinition.builder()
                                .name("eval")
                                .description("""
                                        指定されたjavascriptのコードを実行して結果を返します。
                                        最後に評価された式の結果がコード全体の実行結果となります。
                                        返される結果は文字列型に変換されます。
                                        """)
                                .parameters(Map.of("code", ParameterDetail.builder()
                                        .description("""
                                                実行したいjavascriptのコードを指定します。
                                                 """)
                                        .type(ParameterType.STRING)
                                        .required(true)
                                        .build()))
                                .build())
                        .build())
                .build();

        // 作成したActionGroup定義を登録する
        actionGroups.add(jsexecutor);
    }

    /**
     * ツール実行要求に対して結果を構築し返す。
     * 
     * @param payload ツール実行要求
     * @return ツール実行結果
     */
    private Collection<InvocationResultMember> buildInvocationResultMembers(
            final InlineAgentReturnControlPayload payload) {

        final Collection<InvocationResultMember> results = new ArrayList<>();

        for (final InvocationInputMember member : payload.invocationInputs()) {
            final FunctionInvocationInput input = member.functionInvocationInput();
            String output;

            try {
                // ツール実行要求の中身を取得して提供しているツールに対する実行要求かどうか確認する
                final String actionGroupName = input.actionGroup();
                final String functionName = input.function();
                if (!"CodeInterpreterAction".equals(actionGroupName) || !"eval".equals(functionName)) {
                    throw new NoSuchElementException("The specified actionGroup and function is not supported.");
                }
                final String code = input.parameters().stream().filter(p -> "code".equals(p.name())).map(p -> p.value())
                        .findFirst().orElse(null);
                if (code == null) {
                    throw new IllegalArgumentException("The code parameter is required.");
                }
                // 想定された内容であれば該当する処理を実行して結果を返す
                output = this.eval(code);
            } catch (Exception e) {
                // エラーが発生した場合はエラーの内容を実行結果として返す
                output = e.getMessage();
                this.logger.error("An error occurred while running the tool.", e);
            }
            results.add(InvocationResultMember.builder().functionResult(
                    FunctionResult.builder()
                            .actionGroup(input.actionGroup())
                            .function(input.function())
                            .responseBody(Collections.singletonMap("TEXT",
                                    ContentBody.builder().body(output).build()))
                            .build())
                    .build());
        }

        // ツール実行要求に対する全ての実行結果を返す
        return results;
    }

    /**
     * JavaScriptコードを実行して結果を返す。エージェントに提供しているツールの実装。
     *
     * @param code JavaScriptコード
     * @return 実行結果の文字列
     */
    private String eval(final String code) {
        return Context.create().eval("js", code).asString();
    }
}

メインループ

クライアントとエージェントの間の対話を実装しているメインループです。

        // エージェントからのツール実行要求が無くなるまでエージェントとのコミュニケーションを継続する
        // ツール実行要求が無くなった時点で最終的な応答が返されるので、その値を取得する
        do {
            final Collection<AgentActionGroup> actionGroups = new ArrayList<>();
            this.registerAgentActionGroups(actionGroups); // エージェントが利用可能なツール類を登録

            // 前回の実行要求をクリアしてからエージェントの実行要求を送信
            // 応答を受信する過程で、エージェントからのツール実行要求や最終的な応答の取得を行う
            returnControls.clear();
            this.bedrockAgentRuntimeAsyncClient.invokeInlineAgent(
                    this.buildInvokeInlineAgentRequest(sessionId, prompt,
                            sessionState.size() > 0 ? sessionState.pop() : null, // セッション属性がある場合のみセッションを指定
                            actionGroups),
                    this.buildInvokeInlineAgentResponseHandler(output, returnControls))
                    .join();

            // 前回の実行結果をクリアしてから受信したツール実行要求を処理
            results.clear();
            for (final InlineAgentReturnControlPayload payload : returnControls) {
                this.logger.info("process the payload: " + payload);
                // ツール実行要求を処理した結果を登録する
                results.addAll(this.buildInvocationResultMembers(payload));
            }

            // ツール実行結果が存在する場合、セッションの属性として登録する
            // セッションの属性は、次回のエージェント実行要求に含めてエージェントに送信される
            if (results.size() > 0) {
                sessionState.push(InlineSessionState.builder()
                        .invocationId(returnControls.peek().invocationId())
                        .returnControlInvocationResults(results)
                        .build());
                this.logger.info("the next session state: " + sessionState.peek());
            }

        } while (returnControls.size() > 0);

エージェントがツール実行要求で応答しなくなるまでループし続けます。エージェントがツール実行要求で応答しない場合はループを抜けます。この時点で最終的な回答がoutputに格納されています。

Inline Agent実行要求の組み立て

Bedrock Inline AgentのinvokeInlineAgentアクションを実行するためのリクエストを構築するメソッドです。

    private InvokeInlineAgentRequest buildInvokeInlineAgentRequest(final String sessionId, final String inputText,
            final InlineSessionState sessionState, final Collection<AgentActionGroup> actionGroups) {

        // 認証済みユーザーの権限に応じて利用する基盤モデルを切り替える
        final Collection<String> roles = new ArrayList<>();
        Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
                .ifPresent(a -> a.getAuthorities().stream().forEach(b -> roles.add(b.getAuthority().toUpperCase())));
        final String foundationModel = roles.contains("ROLE_PREMIUM") ? "apac.anthropic.claude-3-5-sonnet-20241022-v2:0"
                : "apac.amazon.nova-micro-v1:0";
        this.logger.info("Selected foundation model: " + foundationModel);

        // 実行要求を組み立てる
        return InvokeInlineAgentRequest.builder()
                .foundationModel(foundationModel)
                .instruction("""
                        あなたは以下の手順にしたがってユーザーの入力に対する応答を作成します。
                        * ユーザーが要求する内容を理解します。
                        * 要求された内容を達成するための作業項目を定義し、作業の実行計画を作成します。
                        * 作業項目ごとに必要となるツールを選定します。
                        * 選定したツールを利用しながら作業を実行し、最終的な応答を作成します。
                        """)
                .sessionId(sessionId)
                .inputText(sessionState == null ? inputText : null) // セッションに属性がある場合はプロンプトは無視されるので渡さない
                .inlineSessionState(sessionState)
                .actionGroups(actionGroups)
                .enableTrace(true)
                .build();
    }

この例では認証済みユーザーの持つ権限に応じてエージェントが利用する基盤モデルを切り替える処理を組み込んでいます。

より個人ごとにカスタマイズされたエージェントを提供するのであれば、ユーザーのプロフィールや過去の行動履歴に基づいてinstructionknowledgebaseなどの属性を変更するようなことも可能です。

AgentGroup定義の組み立てと登録

エージェントに提供するツールの定義を組み立ててコレクションに登録するメソッドです。Bedrock Inline AgentにおいてはAgentGroupと呼ばれます。ActionGroup -> FunctionDefinition -> ParameterDetailというツリー構造になっています。

    private void registerAgentActionGroups(final Collection<AgentActionGroup> actionGroups) {

        // 認証済みではない場合、エージェントに対してツールを提供しない
        if (!Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()).map(a -> a.isAuthenticated())
                .orElse(false)) {
            return;
        }

        // ActionGroup定義を作成する
        // ActionGroup -> FunctionSchema -> ParameterDetail の構造になっている
        // description属性をLLMが解釈して、エージェントがツール実行要求を構築するので明確な内容を記載しておく
        final AgentActionGroup jsexecutor = AgentActionGroup.builder()
                .actionGroupName("CodeInterpreterAction")
                .actionGroupExecutor(ActionGroupExecutor.fromCustomControl(CustomControlMethod.RETURN_CONTROL))
                .description("""
                        javascriptのコードを実行することが出来るツールです。
                        """)
                .functionSchema(FunctionSchema.builder().functions(
                        FunctionDefinition.builder()
                                .name("eval")
                                .description("""
                                        指定されたjavascriptのコードを実行して結果を返します。
                                        最後に評価された式の結果がコード全体の実行結果となります。
                                        返される結果は文字列型に変換されます。
                                        """)
                                .parameters(Map.of("code", ParameterDetail.builder()
                                        .description("""
                                                実行したいjavascriptのコードを指定します。
                                                 """)
                                        .type(ParameterType.STRING)
                                        .required(true)
                                        .build()))
                                .build())
                        .build())
                .build();

        // 作成したActionGroup定義を登録する
        actionGroups.add(jsexecutor);
    }

この例では認証情報の有無でエージェントにツールを公開するかどうかの分岐を実装しています。Inline Agent実行要求と同様に、ActionGroup定義も様々な条件に応じて内容を動的に変更することが可能なので、個人用にカスタマイズされたエージェントの実現に非常に効果的な機能だと言えます。

ツールの実行と結果取得

エージェントから受信したツール実行要求を受け取って、ツールを実行し、結果をまとめるメソッドです。SessionStateとしてまとめられた結果は、メインループ内の次のInvokeInlineAgentアクションによってエージェントに連携されます。

    private Collection<InvocationResultMember> buildInvocationResultMembers(
            final InlineAgentReturnControlPayload payload) {

        final Collection<InvocationResultMember> results = new ArrayList<>();

        for (final InvocationInputMember member : payload.invocationInputs()) {
            final FunctionInvocationInput input = member.functionInvocationInput();
            String output;

            try {
                // ツール実行要求の中身を取得して提供しているツールに対する実行要求かどうか確認する
                final String actionGroupName = input.actionGroup();
                final String functionName = input.function();
                if (!"CodeInterpreterAction".equals(actionGroupName) || !"eval".equals(functionName)) {
                    throw new NoSuchElementException("The specified actionGroup and function is not supported.");
                }
                final String code = input.parameters().stream().filter(p -> "code".equals(p.name())).map(p -> p.value())
                        .findFirst().orElse(null);
                if (code == null) {
                    throw new IllegalArgumentException("The code parameter is required.");
                }
                // 想定された内容であれば該当する処理を実行して結果を返す
                output = this.eval(code);
            } catch (Exception e) {
                // エラーが発生した場合はエラーの内容を実行結果として返す
                output = e.getMessage();
                this.logger.error("An error occurred while running the tool.", e);
            }
            results.add(InvocationResultMember.builder().functionResult(
                    FunctionResult.builder()
                            .actionGroup(input.actionGroup())
                            .function(input.function())
                            .responseBody(Collections.singletonMap("TEXT",
                                    ContentBody.builder().body(output).build()))
                            .build())
                    .build());
        }

        // ツール実行要求に対する全ての実行結果を返す
        return results;
    }

今回の実装ではクライアント実装内部のメソッド呼び出しとしてツールとツールの関数を実装しています。前述したとおり、このツール実行の実装方法は自由なため、メソッド呼び出しのほかに、HTTP通信やメッセージングサービスへのpush、サブプロセスの起動、など様々な選択肢があります。もちろん、ツール実装としてMCPクライアントを準備し、MCPサーバーに接続して外部のツールを実行するようなことも鹿野です。

JUnitによる単体テスト

Bedrock Inline Agentはデプロイ不要なためローカルでの変更を直ちにローカル実行で確認できるソリューションです。また、Return Controlはツール実行ために依存関係のあるプロセスを起動するようなことを回避してくれます。この特徴からInline Agentを利用した実装を対象とした単体テストをJUnitで記述し、自動化することも比較的容易に実現することが出来ます。

SimpleInlineAgentServiceTest
package com.mahitotsu.tsukumogami.apl.service;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.test.context.support.WithMockUser;

import com.mahitotsu.tsukumogami.apl.TestBase;

public class SimpleInlineAgentServiceTest extends TestBase {

    @Autowired
    private SimpleInlineAgentService inlineAgentService;

    @Test
    @WithMockUser(username = "usr001@test.local", roles = "standard")
    public void testAuthorizedStandardUser() {

        System.out.println("----------");
        final String response = this.inlineAgentService.execute("""
                今日から10000日後の日付を教えてください。
                結果は必ず'yyyy-mm-dd'のフォーマットで、計算結果以外の文字は一切含めないでください。
                """);

        assertEquals(LocalDate.now().plusDays(10000).format(DateTimeFormatter.ISO_DATE), response);
    }

    @Test
    @WithMockUser(username = "usr002@test.local", roles = "premium")
    public void testAuthorizedPremiumUser() {

        System.out.println("----------");
        final String response = this.inlineAgentService.execute("""
                今日から10000日後の日付を教えてください。
                結果は必ず'yyyy-mm-dd'のフォーマットで、計算結果以外の文字は一切含めないでください。
                """);

        assertEquals(LocalDate.now().plusDays(10000).format(DateTimeFormatter.ISO_DATE), response);
    }

    @Test
    public void testUnauthorized() {

        System.out.println("----------");
        final String response = this.inlineAgentService.execute("""
                今日から10000日後の日付を教えてください。
                結果は必ず'yyyy-mm-dd'のフォーマットで、計算結果以外の文字は一切含めないでください。
                """);

        assertNotEquals(LocalDate.now().plusDays(10000).format(DateTimeFormatter.ISO_DATE), response);
    }
}

各テストメソッドの実行結果を見ていきます。

testAuthorizedStandardUser

"standard"のロールを持つ認証済みユーザーとしてエージェントを利用するケースです。

2025-05-07T10:02:40.761+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : Selected foundation model: apac.amazon.nova-micro-v1:0
2025-05-07T10:02:45.355+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : process the payload: InlineAgentReturnControlPayload(InvocationId=d35b0911-f4b2-4ef1-8309-adff50a77054, InvocationInputs=[InvocationInputMember(FunctionInvocationInput=FunctionInvocationInput(ActionGroup=CodeInterpreterAction, ActionInvocationType=RESULT, AgentId=INLINE_AGENT, Function=eval, Parameters=[FunctionParameter(Name=code, Type=string, Value=const today = new Date(); const futureDate = new Date(today.setDate(today.getDate() + 10000)); const year = futureDate.getFullYear(); const month = String(futureDate.getMonth() + 1).padStart(2, '0'); const day = String(futureDate.getDate()).padStart(2, '0'); `${year}-${month}-${day}`)]))])
2025-05-07T10:02:46.144+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : the next session state: InlineSessionState(InvocationId=d35b0911-f4b2-4ef1-8309-adff50a77054, ReturnControlInvocationResults=[InvocationResultMember(FunctionResult=FunctionResult(ActionGroup=CodeInterpreterAction, Function=eval, ResponseBody={TEXT=ContentBody(Body=2052-09-22)}))])
2025-05-07T10:02:46.144+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : Selected foundation model: apac.amazon.nova-micro-v1:0
2025-05-07T10:02:47.045+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : Result: 2052-09-22

standardロールのユーザーなので、基盤モデルは安価なAmazon Nova Microになっています。エージェントは開発者が意図したとおりユーザーが要求する日付(今日から10000日後)を算出するjavascriptのコードを生成し、ツールを利用して生成したコードを実行して、ユーザーの要求に応えています。

参考までに。Amazon Nova Microが生成した「今日から10000日後」を計算するコード。

const today = new Date();
const futureDate = new Date(today.setDate(today.getDate() + 10000));
const year = futureDate.getFullYear();
const month = String(futureDate.getMonth() + 1).padStart(2, '0');
const day = String(futureDate.getDate()).padStart(2, '0');
`${year}-${month}-${day}`

testAuthorizedPremiumUser

"premium"のロールを持つ認証済みユーザーとしてエージェントを利用するケースです。

2025-05-07T10:02:47.070+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : Selected foundation model: apac.anthropic.claude-3-5-sonnet-20241022-v2:0
2025-05-07T10:02:52.577+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : process the payload: InlineAgentReturnControlPayload(InvocationId=5629a6bf-2e5c-4c8b-9b2f-5d46c421c667, InvocationInputs=[InvocationInputMember(FunctionInvocationInput=FunctionInvocationInput(ActionGroup=CodeInterpreterAction, ActionInvocationType=RESULT, AgentId=INLINE_AGENT, Function=eval, Parameters=[FunctionParameter(Name=code, Type=string, Value=const today = new Date();
const futureDate = new Date(today.getTime() + (10000 * 24 * 60 * 60 * 1000));
const year = futureDate.getFullYear();
const month = String(futureDate.getMonth() + 1).padStart(2, '0');
const day = String(futureDate.getDate()).padStart(2, '0');
`${year}-${month}-${day}`)]))])
2025-05-07T10:02:52.630+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : the next session state: InlineSessionState(InvocationId=5629a6bf-2e5c-4c8b-9b2f-5d46c421c667, ReturnControlInvocationResults=[InvocationResultMember(FunctionResult=FunctionResult(ActionGroup=CodeInterpreterAction, Function=eval, ResponseBody={TEXT=ContentBody(Body=2052-09-22)}))])
2025-05-07T10:02:52.631+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : Selected foundation model: apac.anthropic.claude-3-5-sonnet-20241022-v2:0
2025-05-07T10:02:54.458+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : Result: 2052-09-22

premiumロールのユーザーなので、基盤モデルは高価なAnthropic Claude 3.5 Sonnetになっています。こちらもエージェントは開発者が意図したとおりユーザーが要求する日付(今日から10000日後)を算出するjavascriptのコードを生成し、ツールを利用して生成したコードを実行して、ユーザーの要求に応えています。

参考までに。Anthropic Claude 3.5 Sonnetが生成した「今日から10000日後」を計算するコード。futureDate変数の初期化部分がNovaと微妙に違います。

const today = new Date();
const futureDate = new Date(today.getTime() + (10000 * 24 * 60 * 60 * 1000));
const year = futureDate.getFullYear();
const month = String(futureDate.getMonth() + 1).padStart(2, '0');
const day = String(futureDate.getDate()).padStart(2, '0');
`${year}-${month}-${day}`

testUnauthorized

未認証のユーザーとしてエージェントを利用するケースです。

2025-05-07T10:02:54.468+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : Selected foundation model: apac.amazon.nova-micro-v1:0
2025-05-07T10:02:55.450+09:00  INFO 496512 --- [           main] c.m.t.a.s.SimpleInlineAgentService       : Result: 今日の日付を教えてください。

standardロールのユーザーなので、基盤モデルは安価なAmazon Nova Microになっています。エージェントはツールが全く提供されていないためjavascriptのコードを生成は試みていません。本日の日付が取得できれば自力で10000万日後が算出できると判断してユーザーに本日の日付を訪ねることにしたようです。ともあれ、ユーザーが要求した「今日から10000日後」を算出することはできませんでした。

高速な試行錯誤の実現

さて、ここまでBedrock Inline Agentによって素早くPersonalized Agentが実現できそうであることは感じられたかと思います。記事のタイトルには"改善"もついていますが、これはどうなのでしょうか。

実は今回紹介しているコードは、意図した動作になるまでinstructiondescriptionを何回も修正してはJUnitのテストを実行して結果を確認した産物です。デプロイをすることもなく、手元でコードを修正したらmvn testなどとコマンド打つだけで修正による効果を確認することが出来る、そんな開発体験を獲得することが可能です。

結果として、非常に高速な試行錯誤が実現され、納得がいくまで何回でも微修正を重ねることが出来ました。

まとめ

Amazon Bedrock Inline AgentとReturn Controlを活用することで、非常に生産性高く、また安全に実行できるPersonalzed AI Agentを実現することが出来ました。今後時間があれば、springframeworkとの統合を一層進めて様々なタスクや処理の自動化に対応可能なAIエージェントの試作に挑戦していきたいと考えています。

それにしても、基盤モデルたちの能力の高さには日々驚かされます。生成AI凄すぎる。

Discussion