# 我把 Google A2A codelab 的漢堡 AI 代理遷移到 a2a-sdk v1.0，並部署到 Cloud Run 上

> Google A2A codelab 裡的漢堡 AI 代理是以 CrewAI 建構的。我把這個 AI 代理遷移到 a2a-sdk v1.0 並部署到 Cloud Run 上，再讓 TypeScript 用戶端實際連上這個 AI 代理。

- Source: https://oharu121.com/zh-tw/blog/a2a-sdk-v1-migration-crewai-agent-cloud-run-typescript-client/
- Published: 2026-11-01T12:00:00+09:00
- Tags: A2A, CrewAI, Python, Google Cloud, TypeScript, AI 代理

---
## 引言

在[我之前寫的 AIMock 文章](/zh-tw/blog/aimock-deterministic-testing-ai-agent-llm-vectordb-a2a/)裡，買家 AI 代理的測試是以模擬的 A2A 賣家 AI 代理為對象執行的。這篇文章則讓同一個 TypeScript 用戶端連上真實的 AI 代理，也就是 Google A2A codelab 裡的漢堡 AI 代理。我先把這個 AI 代理從 a2a-sdk v0.2 遷移到 v1.0，再部署到 Cloud Run 上。

## A2A 生態系的現狀

研究 A2A 協定時，我找了一下有沒有可以自由互動的公開 AI 代理。

### 目前找得到的公開 AI 代理

- **Hello World Agent**：`https://hello-world-gxfr.onrender.com/.well-known/agent.json`。這是第一個公開的 A2A AI 代理，只會把收到的訊息原樣傳回，但很適合用來確認用戶端能否正常運作
- **A2A Registry Playground**：[a2a-registry.org/playground](https://www.a2a-registry.org/playground)，在這個網頁上可以直接跟已登錄的 AI 代理對話
- **Google codelab**：[Purchasing Concierge](https://codelabs.developers.google.com/intro-a2a-purchasing-concierge)，這份實作教學會把兩個 AI 代理部署到 Cloud Run，並讓這兩個 AI 代理透過 A2A 對話。整個教學在免費額度內就能完成

### 已經有 demo 級的 AI 代理，但還沒有 production 環境的端點

| 已經有的 | 還沒有的 |
| --- | --- |
| Hello world，或是原樣傳回訊息的 AI 代理 | 真實企業公開的正式 A2A 端點 |
| Google codelab 的示範 | Stripe、Twilio 這類廠商的 A2A API |
| 社群做的練習用 AI 代理 | 具備真正實用能力的 AI 代理市集 |

A2A 有超過 150 個組織參與，GitHub 星數也超過 22,000，但**幾乎沒有可以自由互動、又真正有用的公開 AI 代理**。企業導入的 A2A（例如 Azure AI Foundry、Bedrock AgentCore）幾乎都必須通過認證才能使用。

## 在本機執行 Google codelab 的真實 A2A AI 代理

demo 的測試裡，賣家 AI 代理是我用 `http.createServer` 寫成的模擬。下一步，我要在本機執行一個真實的 A2A AI 代理，也就是 Google [Purchasing Concierge](https://codelabs.developers.google.com/intro-a2a-purchasing-concierge) codelab 裡的賣家 AI 代理。

### 複製 repo 並調整

```bash
git clone https://github.com/alphinside/purchasing-concierge-intro-a2a-codelab-starter.git
cd purchasing-concierge-intro-a2a-codelab-starter/remote_seller_agents/burger_agent
```

這個漢堡 AI 代理是一個 A2A 賣家 AI 代理，以 CrewAI、LiteLLM 與 Vertex AI Gemini 建構而成，負責接收並處理使用者的漢堡訂單。

這個 starter repo 需要先做以下調整，才能正常執行：

**1. 改用 Python 3.13**

```toml title="pyproject.toml"
requires-python = ">=3.13"
```

```dockerfile title="Dockerfile"
FROM python:3.13-slim
```

**2. 明確加入 `litellm` 相依套件**

`agent.py` 裡有 `import litellm`，但 `litellm` 並不在 `pyproject.toml` 的相依清單中。以前 `litellm` 是以 CrewAI 的間接相依被安裝進來的，但新版的 CrewAI 已經不再相依 `litellm` 了。

```bash
uv add litellm
```

### 把 a2a-sdk 從 v0.2 遷移到 v1.0

原始程式碼是針對 a2a-sdk v0.2 寫的，但 `>=0.2.16` 這個版本限制實際上會安裝 v1.1.2。v1.1.2 含有破壞性變更，因此執行時出現了 import 錯誤。於是我把程式碼更新成 v1.0 的寫法。

### 為什麼 v1.0 改了這麼多？

Protobuf（Protocol Buffers）是 Google 開發的格式，用來定義訊息的型別：先在 `.proto` schema 裡描述一次，再由產生器替各種語言產生對應的類別。同一則訊息既可以透過 gRPC 以二進位格式傳送，也可以改用 JSON 格式傳送。

在 v1.0 中，A2A 把 `a2a.proto` 從 gRPC 專用的檔案，提升為 JSON-RPC、REST、gRPC 三種綁定共用的[規格來源](https://github.com/a2aproject/A2A/blob/main/docs/whats-new-v1.md)。a2a-sdk v1.0 也跟著把 Pydantic 模型換成由 `a2a.proto` 產生的型別。v1.0 大部分的破壞性變更，都來自這一個決定。

*Figure — ProtoSourceOfTruth: 所有使用端都源自同一份 proto，所以同樣的名稱會同時出現在 Python SDK 裡和實際傳送的資料中。*

| 變更 | 來源 |
| --- | --- |
| `Part(root=TextPart(text="..."))` → `Part(text="...")` | `Part` 是一個 proto 訊息，內容以 `oneof` 定義，所以直接設定欄位即可 |
| `"user"` → `"ROLE_USER"` | 為了符合 ProtoJSON，列舉值改成了 `SCREAMING_SNAKE_CASE` |
| `message/send` → `SendMessage` | 方法名稱沿用 proto 裡的 `rpc SendMessage` |
| `url` → `supported_interfaces` | 每一種綁定都各自宣告成一個 `AgentInterface` |

### 把程式碼改成 v1.0 的寫法

這個 codelab 指定的是 `a2a-sdk>=0.2.16`，也就是 [A2A v0.2](https://github.com/a2aproject/A2A/tree/v0.2.5/specification) 的實作，這一版規格的傳送方法是 `message/send`。主要變更：

| 項目 | v0.2 | v1.0 |
| --- | --- | --- |
| 伺服器建構 | `A2AStarletteApplication` 類別 | `create_jsonrpc_routes()` / `create_agent_card_routes()` 路由工廠 |
| AgentCard | `url` 欄位 | `supported_interfaces` 清單（`AgentInterface`） |
| RequestHandler | 沒有 `agent_card` | `agent_card` 為必填 |
| Part 建構 | `Part(root=TextPart(text="..."))` | `Part(text="...")`（以 Protobuf 為基礎） |
| JSON-RPC 方法 | `message/send` | `SendMessage` |
| Agent Card 路徑 | `/.well-known/agent.json` | `/.well-known/agent-card.json` |
| 協定版本 | 無（隱含 0.3） | 必須帶 `A2A-Version: 1.0` header |
| 錯誤型別 | `ServerError` | 直接使用 `UnsupportedOperationError` |

**`__main__.py`：建構伺服器**

```python title="__main__.py"
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.types import AgentCard, AgentInterface, AgentCapabilities, AgentSkill
from starlette.applications import Starlette

agent_card = AgentCard(
    name="burger_seller_agent",
    description="Helps with creating burger orders",
    version="1.0.0",
    capabilities=AgentCapabilities(streaming=True),
    supported_interfaces=[
        AgentInterface(protocol_binding="JSONRPC", url=agent_host_url)
    ],
    skills=[skill],
)

request_handler = DefaultRequestHandler(
    agent_executor=BurgerSellerAgentExecutor(),
    task_store=InMemoryTaskStore(),
    agent_card=agent_card,  # v1.0 起為必填
)

routes = []
routes.extend(create_agent_card_routes(agent_card))
routes.extend(create_jsonrpc_routes(request_handler, "/"))

app = Starlette(routes=routes)
uvicorn.run(app, host=host, port=port)
```

**`agent_executor.py`：改用以 Protobuf 為基礎的型別**

```python title="agent_executor.py"
from a2a.types import Message, Part, Role, Task
from a2a.utils.errors import UnsupportedOperationError

class BurgerSellerAgentExecutor(AgentExecutor):
    async def execute(self, context, event_queue):
        query = context.message.parts[0].text if context.message else ""
        result = await self.agent.invoke(query, context.context_id)

        # v1.0：直接用 Part(text="...") 建構，不需要 TextPart 包裝
        await event_queue.enqueue_event(
            Message(
                role=Role.ROLE_AGENT,
                parts=[Part(text=str(result))],
                message_id=context.task_id or "msg",
                context_id=context.context_id,
            )
        )
```

**`agent.py`：改成非同步**

CrewAI 的 `kickoff()` 是同步方法，但在 a2a-sdk v1.0 裡，請求處理器是在非同步環境中被呼叫的。在非同步環境裡呼叫同步方法，就會拋出 `Agent execution was invoked synchronously from within a running event loop` 錯誤。

```python title="agent.py" del={1,4} ins={2,5}
def invoke(self, query, sessionId) -> str:
async def invoke(self, query, sessionId) -> str:
    # ...
    response = crew.kickoff(inputs)
    response = await crew.kickoff_async(inputs)
    return response
```

### 確認能正常運作

```bash
# 需要先啟用 Vertex AI API
gcloud services enable aiplatform.googleapis.com

# 在本機啟動
uv run . --host 127.0.0.1 --port 8080
```

確認 Agent Card 的內容：

```bash
$ curl http://127.0.0.1:8080/.well-known/agent-card.json | jq .name
"burger_seller_agent"
```

用 A2A v1.0 送一則訊息：

```bash
$ curl -X POST http://127.0.0.1:8080/ \
  -H "Content-Type: application/json" \
  -H "A2A-Version: 1.0" \
  -d '{"jsonrpc":"2.0","id":"1","method":"SendMessage",
       "params":{"message":{"role":"ROLE_USER",
       "parts":[{"text":"What burgers do you have?"}],
       "messageId":"m1","contextId":"ctx1"}}}'
```

回應：

```json
{
  "result": {
    "message": {
      "role": "ROLE_AGENT",
      "parts": [{
        "text": "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, Spicy Chicken Burger for IDR 80K, and Spicy Cajun Burger for IDR 85K. Do any of these sound good?"
      }]
    }
  },
  "id": "1",
  "jsonrpc": "2.0"
}
```

整條路徑都能正常運作了：Vertex AI Gemini → CrewAI → A2A v1.0。

### 部署到 Cloud Run

在本機能夠正常運作之後，下一步是部署到 Cloud Run。這個漢堡 AI 代理是由 uvicorn 啟動的 Starlette ASGI 伺服器，所以適合部署到 **Cloud Run**，而不是 Cloud Functions。這個 AI 代理提供好幾個 Starlette 路由（`/.well-known/agent-card.json`，以及位於 `/` 的 JSON-RPC 端點）。

> **話說**
>
> Starlette 是一個 Python 的 ASGI 網頁框架，uvicorn 則是負責執行 Starlette 應用程式的伺服器。兩者合起來，就是一個會持續監聽連接埠的 HTTP 伺服器。Cloud Run 執行的正是這種會監聽連接埠的容器；Cloud Functions 則只會針對每個請求呼叫單一函式，放不進一整個帶有自己路由的伺服器。

```bash
gcloud run deploy burger-agent \
  --source . \
  --port 8080 \
  --allow-unauthenticated \
  --region us-central1 \
  --min-instances 0 \
  --max-instances 1 \
  --memory 1Gi
```

`--source .` 會在 Cloud Build 上執行 Dockerfile 建置，把映像檔存到 Artifact Registry，再部署到 Cloud Run。

部署之後，我把 `HOST_OVERRIDE` 設成服務的 URL，讓 Agent Card 的 `url` 欄位指向 Cloud Run：

```bash
gcloud run services update burger-agent \
  --region us-central1 \
  --set-env-vars HOST_OVERRIDE=https://burger-agent-XXXXXXXXXX.us-central1.run.app
```

確認結果：

```bash
$ curl https://burger-agent-XXXXXXXXXX.us-central1.run.app/.well-known/agent-card.json | jq .name
"burger_seller_agent"

$ curl -X POST https://burger-agent-XXXXXXXXXX.us-central1.run.app/ \
  -H "Content-Type: application/json" \
  -H "A2A-Version: 1.0" \
  -d '{"jsonrpc":"2.0","id":"1","method":"SendMessage",
       "params":{"message":{"role":"ROLE_USER",
       "parts":[{"text":"What burgers do you have?"}],
       "messageId":"m1","contextId":"ctx1"}}}'
```

```json
{
  "result": {
    "message": {
      "role": "ROLE_AGENT",
      "parts": [{
        "text": "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, Spicy Chicken Burger for IDR 80K, and Spicy Cajun Burger for IDR 85K. What would you like to order?"
      }]
    }
  }
}
```

**關於成本**：設定 `--min-instances 0` 之後，服務閒置時執行個體的數量會降到零，所以閒置中的 demo 不會產生任何費用。代價是 10–15 秒的冷啟動，不過對 demo 或測試來說沒有問題。

## 讓 TypeScript 買家 AI 代理支援 A2A v1.0

要連上 Cloud Run 上的真實賣家 AI 代理，TypeScript 的 A2A 用戶端也必須改成符合 v1.0 的寫法。

### A2A 用戶端的變更

```ts title="src/buyer-agent/a2a-client.ts" ins={4,15,18,19,30,36}
export class A2AClient {
  // v1.0：agent-card.json（原為 agent.json）
  async discover(): Promise<AgentCard> {
    const url = `${this.agentUrl}/.well-known/agent-card.json`;
    const res = await fetch(url);
    this.card = await res.json();
    return this.card;
  }

  // v1.0：SendMessage 加上 A2A-Version header
  async sendMessage(text: string, contextId?: string): Promise<A2AMessage> {
    const rpcRequest = {
      jsonrpc: "2.0",
      id: messageId,
      method: "SendMessage",  // 原為 "tasks/send"
      params: {
        message: {
          role: "ROLE_USER",  // 原為 "user"
          parts: [{ text }],  // 原為 { type: "text", text }
          messageId,
          contextId,
        },
      },
    };

    const res = await fetch(this.agentUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "A2A-Version": "1.0",  // v1.0 起為必填
      },
      body: JSON.stringify(rpcRequest),
    });

    const rpcResponse = await res.json();
    return rpcResponse.result.message;  // 原為 result（任務物件）
  }
}
```

### v0.1 到 v1.0 的變更整理

我的 TypeScript 用戶端用的是比 SDK 更舊的版本：`tasks/send`、`{ type: "text" }` 與 `agent.json` 都是 [A2A v0.1](https://github.com/a2aproject/A2A/tree/v0.1.0/specification) 的寫法。

| 項目 | v0.1 | v1.0 |
| --- | --- | --- |
| Agent Card 路徑 | `/.well-known/agent.json` | `/.well-known/agent-card.json` |
| JSON-RPC 方法 | `tasks/send` | `SendMessage` |
| role 的值 | `"user"` / `"agent"` | `"ROLE_USER"` / `"ROLE_AGENT"` |
| Part 結構 | `{ type: "text", text: "..." }` | `{ text: "..." }` |
| header | 無 | `A2A-Version: 1.0` |
| 回應 | `result` 是一個 Task（訊息陣列） | `result.message` 是單一 Message |

### 連上 Cloud Run 上的真實 AI 代理

我確認了 TypeScript 的 A2A 用戶端可以跟 Cloud Run 上的真實漢堡 AI 代理對話：

```ts
const client = new A2AClient("https://burger-agent-XXXXXXXXXX.us-central1.run.app");

const card = await client.discover();
// → Name: burger_seller_agent, Skills: ["create_burger_order"]

const reply = await client.sendMessage("What burgers do you have?");
// → role: ROLE_AGENT
// → "We have Classic Cheeseburger for IDR 85K, Double Cheeseburger for IDR 110K, ..."
```

模擬的測試與真實的 AI 代理使用的是同一份 A2A 用戶端程式碼，也是同一個 v1.0 協定。測試時指向 AIMock 的模擬伺服器，在 production 環境則指向 Cloud Run 上的真實 AI 代理，兩者之間只差在指向的 URL 不同。

## 總結

- `A2AStarletteApplication` 已經移除。改用路由工廠（`create_jsonrpc_routes` / `create_agent_card_routes`）搭配標準的 Starlette 應用
- 型別不再是 Pydantic 模型，而是改成由 `a2a.proto` 產生。直接建構即可，例如 `Part(text="...")`
- Agent Card 路徑從 `agent.json` 改成 `agent-card.json`
- JSON-RPC 方法從 `message/send` 改成 `SendMessage`，而且必須帶 `A2A-Version: 1.0` header
- A2A AI 代理是 Starlette ASGI 伺服器，所以要部署到 Cloud Run（Docker 容器），而不是 Cloud Functions（單一函式處理模型）
- 在 Cloud Run 上，`HOST_OVERRIDE` 環境變數決定 Agent Card 對外的 URL
- TypeScript 的 A2A 用戶端也必須改成 v1.0 的寫法：端點路徑、方法名稱、role 的值、header
- 因為模擬與真實 AI 代理共用同一份用戶端程式碼與協定，在測試與 production 環境之間切換只需要改指向的 URL 就好

## 參考連結

- [A2A Protocol](https://a2a-protocol.org)
- [What's New in A2A Protocol v1.0](https://github.com/a2aproject/A2A/blob/main/docs/whats-new-v1.md)
- [Google codelab：A2A Purchasing Concierge](https://codelabs.developers.google.com/intro-a2a-purchasing-concierge)
- [A2A Registry](https://www.a2a-registry.org/)
