本記事ではMicrosoft Foundryを主役に据えます。Azureがメインのクラウドです:
マスターエージェントをホストし、為替レートツールを所有し、すべての計算を行います。Google Cloudはサブ役割を担います — Cloud Run上のADKクライアントで、その唯一の役割はA2A v1.0経由での認証・発見・委譲です。

これはデモの好奇心ではなく、実用的なアーキテクチャです。組織のガバナンス、モデル、データが既にAzure上にある場合、Google側にオーケストレーションを委ねることは望まないでしょう。代わりに、自身が制御できないマスターを指し示せる到達可能なクライアントとして位置づけます。本シリーズの前回記事では役割を逆にしてホップを計測しました。今回はFoundryが呼び出される側になった場合の変化についてです — ほぼ全ての変更は認証と発見に関わるもので、プロトコルではありませんでした。

実際にデプロイし計測済みです:Cloud Run(us-central1)がFoundryマスター(eastus2)を呼び出し、生の為替レートを使用、結果は末尾に記載します。途中で4つの問題が発生しましたが、いずれもグリーンのテストスイートでは見えないものでした。それらが本記事の焦点です。

作業の所在

Google Cloud Run
Google ADK client (RemoteA2aAgent)
      |
      +-- A2A v1.0 / JSON-RPC + Microsoft Entra
             |
             v
Microsoft Foundry hosted master (Microsoft Agent Framework)
      |
      +-- MCP stdio --> Frankfurter exchange rates

Enter fullscreen mode Exit fullscreen mode

重要な設計判断:ツールはマスターと共に存在することです。FoundryがMCPレートツールとすべてのDecimal算術を所有し、ADK側はクライアントセッションを所有するだけの薄い認証プロキシです。これにより、Azureが意味のある形でメインクラウドとなり、装飾的な存在ではなくなります — 機能はそこにあり、Google側は自ら為替の質問に答えられません。

これにより実験の誠実さも保たれます。FoundryをマスターにしてもツールをADK側に残した場合、クロスクラウドマスターのテストにはなりません — 追加のレイテンシを挟んだ自前のMCPサーバーの二重テストになります。2つの役割割り当ては別々のディレクトリ(foundry_master/google_adk_client/、前回のcoordinator/adk_agent/に対して)に存在するため、どちらも他方の依存関係に静かに陥ることはなく、再帰的な呼び出しもありません。

第一部:電話に出るFoundryエージェント

マスターは通常のAgent Frameworkエージェントです。ホスト型エージェントであることを示す唯一の要素はResponsesHostServerです:

from agent_framework import Agent, MCPStdioTool
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer

def build_agent() -> Agent:
    client = FoundryChatClient(
        project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
        model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
        credential=DefaultAzureCredential(),
    )
    return Agent(
        client=client,
        name="currency-master-agent",
        description="Master currency agent called remotely by Google ADK.",
        instructions=INSTRUCTIONS,
        tools=[build_rate_tool()],
        default_options={"store": False},
    )

ResponsesHostServer(build_agent()).run()

Enter fullscreen mode Exit fullscreen mode

指示は意図的に無味乾燥にしています。マスター側が算術を行うため、言語モデルを近づけたくないからです:

変換のたびに、要求された各ターゲットに対してconvert_currencyを1回呼び出してください。
ツールのdecimal文字列を正確にコピーし、自身で算術を行ったり検証したりしないでください。ターゲットごとに1つのJSONオブジェクトのみを返し、他のテキストは含めないでください。

レートツールはMCP stdioサブプロセスで、ホスト型コンテナ内で起動されます:

MCPStdioTool(
    name="currency_rates",
    command=sys.executable,
    args=["-m", "mcp_server.server"],
    env={"PYTHONPATH": os.getenv("PYTHONPATH", os.getcwd()), ...},
    load_prompts=False,
)

Enter fullscreen mode Exit fullscreen mode

-m mcp_server.serverが最初の非自明な問題の所在です。デプロイバンドルはリポジトリルートではなくfoundry_master/ディレクトリであるため、MCPサブプロセスはmcp_server/coordinator/をインポートできません — コードは単純にイメージ内に存在しないのです。デプロイヘルパーはazdにディレクトリを渡す前に同期処理を行います:

rsync -a --delete "$REPO_ROOT/coordinator/" "$MASTER_DIR/coordinator/"
rsync -a --delete "$REPO_ROOT/mcp_server/"  "$MASTER_DIR/mcp_server/"
cd "$MASTER_DIR" && azd provision && azd deploy

Enter fullscreen mode Exit fullscreen mode

エレガントではありません。しかしMCP stdioツールは実際のサブプロセスと実際のインポートパスを意味し、ホスト型ランタイムはモノレポレイアウトを考慮しないのです。

azure.yamlはモデルデプロイとホスティングプロトコルを同時に宣言します。これはFoundry azdプロバイダーの優れた特性です — 1つのファイルでgpt-5-miniとそれを使用するエージェントをプロビジョニングできます:

services:
  currency-master-agent:
    host: azure.ai.agent
    codeConfiguration:
      dependencyResolution: remote_build
      entryPoint: main.py
      runtime: python_3_13
    protocols:
      - protocol: responses
        version: 2.0.0

Enter fullscreen mode Exit fullscreen mode

このprotocolsリストが示すものに注意してください:responses。A2Aではありません。

第二部:受信A2Aはオプトインであり、プレビューです

ホスト型FoundryエージェントはResponsesプロトコルで通信します。明示的に有効化するまでインバウンド呼び出し元に対してA2Aを話しません。また執筆時点では、azdサーフェスを持たないプレビュー機能です。これはエージェントに対するPATCHであり、正しく処理すべき2つの要素があります:

{
    "agent_card": {
        "description": "Master currency agent using live MCP rates and Decimal arithmetic.",
        "version": "1.0",
        "skills": [{"id": "currency-conversion", "name": "Currency conversion", ...}],
    },
    "agent_endpoint": {"protocol_configuration": {"responses": {}, "a2a": {}}},
}

Enter fullscreen mode Exit fullscreen mode

第一に、エージェントカードは自身で提供します。ADK(to_a2a()がエージェントからカードを導出し、MCPツールが自動的にスキルとして表示される)からの移行では、手書きのカードは後退に感じられます。しかし誠実さへの一歩でもあります:カードは契約であり、この側ではそれを明示的に述べることが求められるのです。

第二に、パッチは両方のプロトコルを送信し、a2aだけを送信することはありません。これは重要であり、仮定せずにデプロイ済みエージェントに対して検証しました — a2aのみでパッチし、状態を読み戻し、その後復元します:

before:            protocols ['a2a', 'responses']   card 200
patch a2a-only ->  200
after a2a-only:    protocols ['a2a']                card 400
patch both ->      200
after restore:     protocols ['a2a', 'responses']   card 200

Enter fullscreen mode Exit fullscreen mode

したがってprotocol_configuration置き換えであり、マージではありません。{"a2a": {}}のみを送信すると、Responsesは実行中のエージェントから消失します。

第二列は予想外の部分でした:Responsesを削除すると、A2Aエージェントカード自体が400を返し始めました。A2AサーフェスはResponsesプロトコルから独立しておらず、Responsesを無効化するとA2Aも同時にダウンしました。したがって「必要なプロトコルのみを有効化する」という失敗モードは「Responsesが壊れる」ではなく「すべてが壊れる」であり、それを実行したリクエストは陽気な200を返します。

ユニットテストはこの形状を固定し、誰も後から削減できないようにします:

def test_patch_retains_responses_when_enabling_a2a():
    protocols = patch_body()["agent_endpoint"]["protocol_configuration"]
    assert set(protocols) == {"responses", "a2a"}

Enter fullscreen mode Exit fullscreen mode

状態読み戻し時のもう一つの落とし穴:definition.protocol_versionsは正常に動作するA2Aエージェントでもresponsesのみを報告します。このフィールドはコンテナが何を話すかを記述します。有効化はagent_endpoint.protocol_configurationに存在します。最初に誤ったフィールドを確認し、パッチが何もしていないと短時間結論づけました。

第三部:エージェントカードでさえトークンを必要とする

AzureがGoogleを呼び出す方向では、発見は匿名です。URLをGETし、JSONを取得し、スキルを読み取ります。カードは公開メタデータです。

Foundryへの呼び出しでは、匿名的な/.well-known/agent-card.jsonは存在しません。カードはエージェントのプロトコルエンドポイント下のバージョン付きパスに存在します:

{project_endpoint}/agents/currency-master-agent/endpoint/protocols/a2a/agentCard/v1.0

Enter fullscreen mode Exit fullscreen mode

そしてカードフェッチを含むすべてのリクエストはEntraベアラートークンを運びます。呼び出し元のIDはFoundryプロジェクトに対してFoundry Agent Consumerロールが必要です。これはデータプレーンのロール割り当てであり、同じプリンシパルが既に持つ可能性のあるコントロールプレーン権限とは別です。

実務上の結果として予期すべきこと:カードに対する404または401は、誤ったURLと同じくらい役割割り当ての欠如である可能性が高いため、パスセグメントを数え始める前にアクセス制御を確認してください。

発見が認証されることは、率直に述べる価値のある方法で信頼の物語を変更します。ADKが呼び出される側の場合、誰でもそのエージェントが主張する内容を読めます。Foundryをマスターにする場合、機能発見自体が特権操作になります — マスターのスキルを列挙するには、既にそれを呼び出すことを許可されている必要があります。ツールを所有するメインクラウドにとって、これは正しいデフォルトと言えるでしょう。

第四部:Google側にはGoogleのものではない資格情報が必要

これはGoogleがマスターをホストする場合に相当するものが存在しない部分です。Cloud RunコンテナがAzureに対して認証するにはEntraサービスプリンシパルが必要であり、クライアントシークレットがGoogle側に存在する必要があります。これはSecret Managerに保存され、デプロイ時に注入されます — ソース内ではなく、マニフェスト内でもありません:

gcloud run deploy "$SERVICE" \
  --source "$REPO_ROOT/google_adk_client" \
  --no-allow-unauthenticated \
  --set-secrets "AZURE_CLIENT_SECRET=${AZURE_SECRET}:latest" \
  --set-env-vars "FOUNDRY_MASTER_A2A_ENDPOINT=${FOUNDRY_MASTER_A2A_ENDPOINT},AZURE_TENANT_ID=...,AZURE_CLIENT_ID=..."

Enter fullscreen mode Exit fullscreen mode

デプロイヘルパーは生のシークレット値を受け取ることを完全に拒否します — シークレットが既に存在することを主張し(gcloud secrets describe)、存在しない場合は失敗します。シークレットを受け入れる利便性フラグは、シークレットがシェル履歴に残る原因となります。

DefaultAzureCredentialは環境からテナント、クライアントID、シークレットを取得します。アウトバウンドA2Aトラフィックへのトークン添付は小さなhttpx.Authアダプタで行われます — キャッシュされ、有効期限の5分前に更新され、すべてのリクエストにプロトコルバージョンをスタンプします:

class EntraAuth(httpx.Auth):
    requires_request_body = True

    async def async_auth_flow(self, request):
        if self._token is None or self._token.expires_on - 300 <= time.time():
            self._token = await self._credential.get_token("https://ai.azure.com/.default")
        request.headers["Authorization"] = f"Bearer {self._token.token}"
        request.headers["A2A-Version"] = "1.0"
        yield request

Enter fullscreen mode Exit fullscreen mode

スコープはhttps://ai.azure.com/.default — ARMオーディエンスではなくAIサービスオーディエンスです。誤ったオーディエンスに対するトークンは完全に有効なトークンですが拒否され、パーミッションバグとは異なる様相の401を生成します。

そしてこれが統合のGoogle側の全体です:

def build_root_agent() -> RemoteA2aAgent:
    client = httpx.AsyncClient(auth=EntraAuth(), timeout=httpx.Timeout(120))
    return RemoteA2aAgent(
        name="foundry_currency_master",
        description="Authenticated ADK proxy to the Azure Foundry currency master.",
        agent_card=agent_card_url(os.environ["FOUNDRY_MASTER_A2A_ENDPOINT"]),
        httpx_client=client,
        timeout=120,
        full_history_when_stateless=False,
    )

Enter fullscreen mode Exit fullscreen mode

RemoteA2aAgentが注入されたhttpx_clientを受け入れることが、これをADKをパッチせずに可能にするものです。この統合の異常な点すべて — Entraトークン、非標準的なカードパス、120秒のクロスクラウドタイムアウト — は渡すクライアントに収まります。これは genuinely良い拡張ポイントであり、「ADKがAzureを呼び出す」がフォークではなく30行で済む理由です。

フラグを立てる価値のある2つの小さな選択:full_history_when_stateless=Falseはプロキシが各ホップで蓄積された会話を再送信することを防ぎ、120秒のタイムアウトは前回の記事から学んだ教訓の再確認です — 1つのクラウド向けに調整されたデフォルトは2つには通用しません。コールドスタートに加えてモデル生成とライブレートコールは、試さなくても10秒を超えます。

ユニットテストでは見えないパッケージングバグ

クライアントイメージ内の2つのバグは、コンテナのディレクトリレイアウトをローカルに再構築し、ADKのAgentLoaderをそれに対して実行することで発見されました — Cloud Runでの失敗を観察したわけではありません。

イメージはadk api_server /appを実行し、/appagent.pyentra_auth.pyの隣に保持します。ADK 2.5.0はこれをsingle-agent modeとして検出します:ディレクトリの親をエージェントディレクトリとして取り、ファイルをapp.agentとしてインポートします。したがってエージェントモジュールはパッケージのサブモジュールであり、トップレベルスクリプトではありません。

つまり、agent.py内のこれは動作しません:

from entra_auth import EntraAuth      # ModuleNotFoundError: No module named 'entra_auth'

Enter fullscreen mode Exit fullscreen mode

そしてこれは動作します:

from .entra_auth import EntraAuth

Enter fullscreen mode Exit fullscreen mode

ローダーは/appではなく/sys.pathに配置するため、フラットインポートには解決元の場所がありません。相対インポートは両方のコンテキストで動作します — コンテナ内と、リポジトリのユニットテストが同じモジュールをgoogle_adk_client.entra_authとしてインポートする場合です。

同じミスのもう半分:COPY pyproject.toml uv.lock agent.py ./という行がentra_auth.pyに言及していないため、モジュールはイメージ内に全く存在しません。ビルド時には何も失敗しません — 失敗するのはインポート時です。

どちらもグリーンのテストスイートでは見えません。テストはソースツリーからgoogle_adk_client.entra_authをインポートし、出荷されるのはソースツリーではないからです。再構築された/appレイアウトを読み込むことは、両方をキャッチする2行のチェックです:

from google.adk.cli.utils.agent_loader import AgentLoader
AgentLoader("/app").load_agent("app")   # ModuleNotFoundError before the fix

Enter fullscreen mode Exit fullscreen mode

デプロイ

順序が重要です。ADKクライアントはマスターが起動し、A2Aが有効化されるまで存在しないエンドポイントを必要とするからです:

./infra/deploy_foundry_master.sh          # provision, deploy, then PATCH incoming A2A on
export FOUNDRY_MASTER_A2A_ENDPOINT="https://.../endpoint/protocols/a2a"
export GCP_PROJECT="your-project" AZURE_TENANT_ID="..." AZURE_CLIENT_ID="..."
./infra/deploy_google_adk_client.sh       # ADK proxy on Cloud Run, secret from Secret Manager

Enter fullscreen mode Exit fullscreen mode

有効化スクリプトは解決済みのカードとエクスポートするエンドポイントを出力するため、2番目のステップは手動でURLを組み立てるのではなくコピー&ペーストです。

実行時にのみ壊れた2つのもの

ユニットテストは常にグリーンでした。これらはどちらもテストでは見えませんでした。

v1.0メソッド名はv0.3メソッド名ではありません。 message/send(ほとんどのA2Aサンプルがまだ示すv0.3の綴り)を使用した手書きのJSON-RPC呼び出しは、以下のように返されます:

{"error": {"code": -32601, "message": "Method not found",
           "data": [{"reason": "METHOD_NOT_FOUND",
                     "metadata": {"detail": "'method' field is not a valid A2A method."}}]}}

Enter fullscreen mode Exit fullscreen mode

a2a-sdk 1.xではJSON-RPCメソッドはSendMessageで、proto-JSONパラメータを使用します。Foundryカードが同じURL上に3つのインターフェース — JSONRPC 1.0、JSONRPC 0.3、HTTP+JSON 0.3 — を広告していることを知っておく価値があります。したがってサーバーが制約しているわけではなく、クライアントの綴りが制約しているのです。

コンテナには宣言されていない依存関係が必要でした。 azure.identity.aioaiohttpで非同期トランスポートを構築しますが、これはazure-identityのハード依存関係ではありません。通常のワークステーションには存在するため、ローカル実行はパスします。uv sync --frozenコンテナでは存在せず、ビルドは成功しますが、最初のトークンリクエストで死にます:

File "azure/core/pipeline/transport/__init__.py", line 94, in __getattr__
    raise ImportError("aiohttp package is not installed")

Enter fullscreen mode Exit fullscreen mode

両方のバグは同じ形状を共有しています:ロックファイルとテストが実行されるソースツリーは、出荷される成果物ではありません。

結果

以下の数値はすべてライブデプロイメントからのものです — Cloud Run(us-central1)がFoundryマスター(eastus2)を呼び出し、gpt-5-mini、生のFrankfurterレート、プロンプトConvert 250 GBP to USD and JPY.

測定項目
認証済みエージェントカードGET(ウォーム中央値、6回実行) 0.35 s
同、初回フェッチ 0.51 s
A2A SendMessage、ワークステーション→マスター(ウォーム中央値、6回実行) 21.2 s
Cloud Run ADKクライアント経由のA2Aラウンドトリップ(中央値、5回実行) 23.4 s
同、範囲 18.9 s – 29.5 s
新しいCloud Runリビジョンに対する初回リクエスト 20.8 s

すべての実行で同じ数値が返され、それらは正しいものでした:

{"source_currency":"GBP","target_currency":"USD","rate":"1.3389",
 "converted_amount":"334.7250","source":"frankfurter-live"}
{"source_currency":"GBP","target_currency":"JPY","rate":"218.15",
 "converted_amount":"54537.50","source":"frankfurter-live"}

Enter fullscreen mode Exit fullscreen mode

3つの点が際立っています。

認証済み発見は安価です。 懸念していたのは、トークンと役割チェックを必要とするカードフェッチが匿名GETより意味のあるほど高価になることでした。ウォーム時0.35秒では先行する呼び出しに対してノイズであり、トークンは呼び出し間でキャッシュされます。

ADKプロキシホップは約2秒のコストです — Cloud Run経由の23.4秒に対して直接の21.2秒、18.9–29.5秒の範囲に対して。率直に言えば、マスターの実行間分散はパスに2番目のクラウドを置くコスト全体より大きいのです。

マスターがすべてを支配します。 23秒のラウンドトリップのうち約20秒は、gpt-5-miniがconvert_currencyを2回呼び出し、Frankfurterを待機する時間です。このアーキテクチャを高速化したい場合、プロトコルとプロキシは時間がかかっている場所ではありません。前回の記事のAzureがGoogleをオーケストレーションする数値(a2a_only中央値1.69秒)との対比に注意してください:同じプロトコル、同じクラウド、1桁の差 — なぜなら前者ではリモートエージェントが1つの質問に答え、ここではマスターがマルチツール推論ループを実行するからです。どちらの場合も測定しているのはプロトコルオーバーヘッドではありません。

初回デプロイを予算化する人向けのインフラストラクチャタイミング:azd provision 64秒、azd deploy 116秒、そして混乱を招くもの — データプレーンRBACが有効になるまで105秒az role assignment listで既に役割割り当てが表示されているにもかかわらず、エージェント書き込みは403を返し続けました。これは伝播であり、設定ミスではありません。役割を再割り当てし始める前に待機してください。

生の結果はevaluations/results/foundry-master-live-2026-07-31.jsonにあり、evaluations/measure_foundry_master.pyがそれらを再生成します。

スコープに関する注意:これらは単一プロンプトサンプルであり、分布ではありません。前回の記事の38ケース評価マトリックスは、まだこのアーキテクチャに対して再実行されていません。

教訓

  1. Foundryをマスターにすることはプロトコル問題ではなく認証問題です。 JSON-RPCの半分は、どちらのクラウドが担当しても簡単な半分です。アイデンティティ — そしてここで105秒かかったRBAC伝播(割り当ては既に存在として表示されていた) — に時間を割いてください。
  2. 発見は常に無料ではない — しかし安価です。 認証済みエージェントカードは、すべての「カードが見つからない」を「役割割り当てである」と証明されるまで扱います。ウォーム時0.35秒は最適化すべきものではありません。
  3. protocol_configurationは置き換えであり、マージではありません。 検証済み、仮定ではない:a2aのみのパッチはResponsesをドロップし、A2Aカードをそれと共にダウンさせました(400)。それを実行したリクエストは200を返しました。保持したいすべてのプロトコルを送信し、テストで固定してください。
  4. ホスト型ランタイムはリポジトリレイアウトを平坦化します。 MCP stdioツールは実際のサブプロセスと実際のインポートパスを生成します — デプロイバンドルがサブディレクトリの場合、インポートはその中に存在する必要があります。
  5. クロスクラウド資格情報は呼び出し側に存在します。 GoogleがAzureを呼び出すことは、Google Secret Manager内のAzureクライアントシークレットを意味します。シークレットを回避する方法はありません。ソースツリーに置かない方法はあります。
  6. 他の何かをデバッグする前にトークンオーディエンスを正しく取得してください。 誤ったスコープに対する有効なトークンは、パーミッションバグのように見える方法で失敗します。
  7. 注入可能なHTTPクライアントが、ベンダー横断エージェントを安価にするものです。 ADKのRemoteA2aAgenthttpx_clientを受け取ることは、アダプタとフォークの違いです。
  8. テストはソースツリーをインポートする。ユーザーはイメージを実行する。 欠落したCOPY行、パッケージ外でのみ動作するフラットインポート、ワークステーションが偶然持っている推移的依存関係(aiohttp)は、すべてグリーンのテストスイートでは見えませんでした。コンテナの実際のレイアウトを読み込むか、デプロイして呼び出す — これら2つのみがこのクラスのバグを発見します。
  9. パーミッションを確認する前にメソッド名を確認してください。 message/sendはv0.3。SendMessageはv1.x。失敗は完全に認可されているエンドポイントに対するMETHOD_NOT_FOUNDです。
  10. プロトコルは決して遅い部分ではありません。 23秒のラウンドトリップのうち~20秒はマスター自身の推論とツール呼び出しでした。2番目のクラウドのコストは約2秒 — マスターの実行間分散より小さいです。

ソース

両方の役割割り当て、デプロイスクリプト、テスト:

GitHub - xbill9/foundry-adk-a2a-currency

Foundry-as-masterコンポーネントはfoundry_master/google_adk_client/infra/enable_foundry_master_a2a.pyです。測定値はevaluations/measure_foundry_master.pyから来ており、生の出力はevaluations/results/foundry-master-live-2026-07-31.jsonにあります。Googleがリモートエージェントをホストする設定とそのベンチマーク結果は、本シリーズの前回の記事にあります。

ホスト型Foundryエージェント上で受信A2Aを実行したことがある場合 — 特に数値が異なる場合、または本記事が見逃したプレビューの癖を見つけた場合 — ぜひ比較させてください。