はじめに
AIエージェント同士をつなぐオープン標準 A2A(Agent2Agent)プロトコル を、SDKを使わない生実装と実測ログで調べていくシリーズの第5回(最終回)です。
- 第1回: A2AでAIエージェント同士はどう会話するのか? — Agent Card と最小エージェントを依存ゼロで作り、curlで話しかける
- 第2回: A2Aエージェントは時間のかかる依頼をどう処理するのか? — Taskライフサイクル・中断と再開・SSE・成果物(Artifact)
- 第3回: A2Aエージェント同士をつなぐと何が起きるのか? — 2体のエージェントをつなぎ、role・ID・中断の伝搬を実測する
- 第4回: A2Aのプッシュ通知はどう動くのか? — webhookで結果を受け取る
- 第5回(本記事): 自作のA2Aエージェントは公式SDK製と会話できるのか? — 挙動差分と0.3系/1.0系の非互換
ここまでの4回は、サーバもクライアントも自作でした。自作同士なら、仕様を同じように読み違えていても会話は成立してしまいます。相互運用のプロトコルとして本当に意味があるのは、別々の人が別々に作ったエージェント同士が会話できるかです。最終回では、公式SDK(@a2a-js/sdk)で作ったエージェントと自作のエージェントを連携させます。やることは次の4つです。
- curlから、公式SDK製のエージェントに依頼を送る
- 自作した受付エージェント(第3回で作った、依頼を別のエージェントに取り次ぐエージェント)から、公式SDK製のエージェントに依頼を送る
- 逆に、公式SDKのクライアントから、自作の足し算ワーカー(第2回で作った足し算エージェント)に依頼を送る
- A2Aプロトコル仕様の1つ前の世代であるv0.3系(本シリーズが扱ってきたのはv1.0)で動くエージェント(Python公式SDK製)を立て、curlから1.0系と0.3系それぞれの形式で話しかけて、何が起きるかを見る
本記事のゴール
次の3点を実感を持って言えるようになることです。
- A2Aの基本の流れ(メソッド名・JSONの形・SSEの形・Taskの状態遷移)は、自作実装と公式SDKで完全に互換だった
- 依頼を受けたエージェントが入力をどこまで厳密に検証するか、そして仕様外の入力に対して返すエラーコードは、実装ごとに差があった。公式SDKにも仕様のエラーコード表と食い違うバージョンがあった(@a2a-js/sdk 1.0.1で観測。その後公開された1.1.0で解消していることを確認)
- A2Aプロトコル仕様のv0.3系とv1.0系ではメソッド名の定義自体が異なり、片方の形式で送った依頼はもう片方のエージェントに「知らないメソッド」または「未対応のバージョン」として断られる。「A2A準拠」という言葉だけでは相互運用の保証にならず、プロトコルバージョンの明示が必要
検証環境: macOS / Node.js v25.6.1 / @a2a-js/sdk 1.0.1 および 1.1.0 / Python 3.13 + a2a-sdk 0.3.26 / A2Aプロトコル v1.0(仕様。本文とコード中の§番号はこの仕様の節番号です)
前提: 第2回の task-server.mjs と第3回の reception-server.mjs を手元に用意してください。本記事はシリーズで唯一、依存ゼロの原則(外部ライブラリを使わず、Node.jsの標準機能だけで実装する)を外れて、公式SDKを外部ライブラリとしてインストールします(npmとpip)。
何を確かめるのか
自作実装が正しく動くかの確認ではありません。ここまでの実装はすべて仕様を読んで自分で埋めた解釈です。仕様が書いていないこと、例えば「不正なMessageをどこまで弾くか」「エラーコードの細部」「Agent CardのETagの付け方」は、実装ごとに異なる可能性があります。仕様が実装に委ねた部分を、別の人が作った実装がどう決めているかを観察し、自作の解釈と突き合わせるのが今回の目的です。
登場するモジュールを先に整理します。モジュール名は本文と図で共通です。
| モジュール名 | 処理内容 | ソースコード | ポート |
|---|---|---|---|
| 自作の足し算ワーカー | 足し算をするエージェント(Slow Calc Agent)。bが無ければ聞き返す |
第2回の task-server.mjs
|
4102 |
| 自作受付 | 依頼を自作の足し算ワーカーに取り次ぐエージェント(Calc Reception Agent)。サーバでありクライアントでもある |
第3回の reception-server.mjs
|
4103 |
| 公式SDKサーバ | 自作の足し算ワーカーと処理内容が同じエージェント(SDK Calc Agent)を公式SDKで書いたもの | 本記事(付録1) | 4104 |
| 公式SDKクライアント | 公式SDKのクライアント機能で依頼を送るスクリプト | 本記事(付録2) | なし |
| 0.3系サーバ | A2A v0.3系で動く挨拶エージェント(Legacy Hello Agent)。Python公式SDK製 | 本記事(付録4) | 4105 |
確認する通信は、以下の4つです。
- 自作クライアント(curl)から公式SDKサーバへ。仕様が実装に委ねた部分を確かめる8つのリクエストを送り、自作の足し算ワーカーの応答と比べる
- 公式SDKクライアントから自作の足し算ワーカーへ。SDKが実際に何を送るかを通信ログで確かめる
- 自作受付から公式SDKサーバへ。第3回の2段構成のワーカーだけを他人の実装に差し替える
- curlから0.3系サーバへ1.0形式と0.3形式で話しかけ、逆に1.0系の自作の足し算ワーカーへ0.3形式で話しかける
公式SDKでエージェントを作る
@a2a-js/sdk はA2Aプロジェクト(Linux Foundation配下)が公開しているJavaScript/TypeScript向けの公式SDKで、v1.0.0(2026-07-22)から仕様v1.0に対応しています。まず、第2回の自作の足し算ワーカーと処理内容が同じエージェント(2つの数を足す。bが無ければ聞き返す)を、公式SDKで書きます。処理内容を自作の足し算ワーカーと同じにしておけば、挙動に差が出たときに「それはSDKのプロトコル層の判断だ」と切り分けられるからです。
SDKの作りは、ここまで自作で手組みしてきたものと対照的です。SDKを活用すると、開発者は AgentExecutor というインターフェースを満たすオブジェクトを1つ実装するだけで、AIエージェントを開発することができます。AgentExecutor は呼び出す関数の名前ではなく、TypeScriptの型(インターフェース)の名前で、execute(依頼を処理する)と cancelTask(取り消しに応じる)の2つの関数を持つオブジェクトなら何でも満たせます。JavaScriptで書いている本記事のコードには、この名前は現れません。AgentExecutor が受け持つのは、依頼を受け取り、処理の進み具合をイベント(Taskの作成、状態の更新、成果物の追加)として流す部分です。それ以外の仕事、つまりブロッキングの待ち合わせ、SSEへの書き出し、Taskの保存、エラー応答は、SDKの DefaultRequestHandler がまとめて担います。第2回で自分で書いた waiters(結果を待っているクライアントの一覧)や streams(開いているSSE接続の一覧)の管理は、SDKを使う開発者は書かなくてよい部分です。
次のコードは、筆者が公式SDKを使って書いたエージェント(sdk-calc-server.mjs、全文は付録1)のうち、AgentExecutor を実装した部分の抜粋です。execute() はSDKが依頼(SendMessage)を受けるたびに呼び出す関数で、引数の ctx に依頼の内容(利用者のMessage、taskId、再開なら既存のTask)が、bus にイベントの流し先が渡されます。中身は第2回の自作の足し算ワーカーと同じ手順です。状態を SUBMITTED で始め、aが無ければ REJECTED、bが無ければ INPUT_REQUIRED で聞き返し、両方あれば WORKING にして1.5秒後に成果物と COMPLETED を流します。
// AgentExecutor の実装。SDKが依頼(SendMessage)を受けるたびに execute() を呼ぶ。
// ここではイベントをバスに流すだけでよく、ブロッキング/SSE/タスク保存/エラー応答は全部SDK(DefaultRequestHandler)の仕事
const executor = {
async execute(ctx, bus) {
const text = (ctx.userMessage.parts ?? []).map(partText).join('');
const input = parseNumbers(text);
const prior = ctx.task; // 再開ターンならSDKが既存Taskを渡してくる
if (!prior) {
// 最初のイベントはtask(SDKの規約)。aは再開時に使うためmetadataに残す
bus.publish(AgentEvent.task({
id: ctx.taskId,
contextId: ctx.contextId,
status: status(TaskState.TASK_STATE_SUBMITTED),
history: [ctx.userMessage],
artifacts: [],
metadata: { a: input.a },
}));
if (input.a === undefined) {
bus.publish(AgentEvent.statusUpdate({
taskId: ctx.taskId,
contextId: ctx.contextId,
status: status(TaskState.TASK_STATE_REJECTED, agentMessage('数値が見つからないため、この依頼はお受けできません', ctx.taskId, ctx.contextId)),
}));
return bus.finished();
}
if (input.b === undefined) {
bus.publish(AgentEvent.statusUpdate({
taskId: ctx.taskId,
contextId: ctx.contextId,
status: status(TaskState.TASK_STATE_INPUT_REQUIRED, agentMessage(`a=${input.a} を受け取りました。b はいくつですか?`, ctx.taskId, ctx.contextId)),
}));
return bus.finished();
}
} else {
bus.publish(AgentEvent.task(prior)); // 再開ターンでも最初のイベントはtask(SDKの規約)
}
const a = prior ? Number(prior.metadata?.a) : input.a;
const b = prior ? (input.b ?? input.a) : input.b;
bus.publish(AgentEvent.statusUpdate({
taskId: ctx.taskId,
contextId: ctx.contextId,
status: status(TaskState.TASK_STATE_WORKING, agentMessage(`${a} + ${b} の計算を始めます`, ctx.taskId, ctx.contextId)),
}));
await sleep(WORK_MS);
bus.publish(AgentEvent.artifactUpdate({
taskId: ctx.taskId,
contextId: ctx.contextId,
artifact: { artifactId: crypto.randomUUID(), name: '計算結果', parts: [textPart(`${a} + ${b} = ${a + b}`)] },
append: false,
lastChunk: true,
}));
bus.publish(AgentEvent.statusUpdate({
taskId: ctx.taskId,
contextId: ctx.contextId,
status: status(TaskState.TASK_STATE_COMPLETED),
}));
bus.finished();
},
// ...cancelTask は省略(付録参照)
};
このExecutorをリクエストハンドラに渡し、Agent CardとJSON-RPCのエンドポイントをExpressに載せます。
const handler = new DefaultRequestHandler(agentCard, new InMemoryTaskStore(), executor);
const app = express();
app.use('/.well-known/agent-card.json', agentCardHandler({ agentCardProvider: handler }));
app.use('/a2a/v1', jsonRpcHandler({ requestHandler: handler, userBuilder: UserBuilder.noAuthentication }));
この公式SDKサーバのソースコード全文は、巻末の付録1に掲載しています。空のディレクトリに sdk-calc-server.mjs として保存し、次の手順で起動します。
npm init -y
npm install @a2a-js/sdk@1.1.0 express
node sdk-calc-server.mjs
自作クライアントから公式SDKサーバに依頼を送る
この章は「何を確かめるのか」で挙げた通信の①です。
curlから公式SDKサーバに、ここまでの回で自作の足し算ワーカーに送ってきたものと同じリクエストを送ります。そして、公式SDKサーバが返す応答を、自作の足し算ワーカーが返していた応答と比べます。手順は次の8ステップです。ステップ1〜3ではA2Aの基本の流れが同じ形で通るかを確かめ、ステップ4〜8では仕様が実装に委ねた部分がどう決められているかを確かめるために、不正なリクエストや境界的なリクエストをわざと送ります。始める前に、共通の事前準備(ステップ0)を済ませてください。
- Agent Cardを取得して、ヘッダを見る
- SendMessageをブロッキングで送る
- SendStreamingMessageでSSEを受ける
- 存在しないtaskIdでGetTaskする
- A2A-Versionヘッダを付けずに送る
- messageIdの無いMessageを送る
- 完了済みのタスクに追加のMessageを送る
- でたらめなrole値を送る
ステップ0: 共通の事前準備
前の章の手順で公式SDKサーバ(ポート4104)を起動しておきます。次に、8ステップで繰り返し使う値をシェル変数に入れます。以降、$S が公式SDKサーバ、$H1 と $H2 が毎回付けるHTTPヘッダです。
S=http://localhost:4104
H1='Content-Type: application/json'
H2='A2A-Version: 1.0'
ステップ1〜8をまとめて実行するスクリプト(try-sdk.sh)も付録3に載せています。1つずつ手で送る代わりに、ログを残しながら一気に流すなら次のように実行してください。
bash try-sdk.sh 2>&1 | tee -a try-sdk-run.log
ステップ1: Agent Cardを取得して、ヘッダを見る
curl -s -i $S/.well-known/agent-card.json
HTTP/1.1 200 OK
ETag: W/"5989eff1be9d8ee4"
Cache-Control: public, max-age=3600
Content-Type: application/json; charset=utf-8
Content-Length: 745
{
"name": "SDK Calc Agent",
"version": "0.1.0",
"supportedInterfaces": [
{
"url": "http://localhost:4104/a2a/v1",
"protocolBinding": "JSONRPC",
"protocolVersion": "1.0"
}
],
"capabilities": {
"streaming": true,
"pushNotifications": false,
"extendedAgentCard": false
}
}
(Agent Cardの skills などは省略しています)
Agent Cardの中身は第1回で作った形と同じです。違いはヘッダの ETag にあります。自作の足し算ワーカーはAgent Cardの version をそのままETagにしましたが(ETag: "0.2.0")、SDKは内容のハッシュ値に W/ を付けて返しています(上のレスポンスヘッダの ETag: W/"5989eff1be9d8ee4")。W/ はHTTPの ETag ヘッダの値の先頭に付ける「弱い検証子(weak validator)」の印で、「中身が意味として同じなら同じ値になる」という保証を表します。バイト単位で完全に一致することまで保証する「強い検証子」(W/ 無し)と区別するためのもので、キャッシュの更新判定(If-None-Match)にはどちらも使えます。仕様(§8.6.1)は「version フィールド由来か、Agent Cardの内容のハッシュ」のどちらかをETagにするべき(SHOULD)としています。自作ワーカーは前者、SDKは後者を選んだだけで、どちらも仕様に基づいた実装です。クライアントから見れば「値が変わったら再取得」という扱いは同じで、中身の作り方が違ってもプロトコル上の意味は揃う、というのが最初の観察です。
ステップ2: SendMessageをブロッキングで送る
第2回と同様に、「19 + 23 を計算してほしい」という依頼({"a": 19, "b": 23})をSendMessageで送ります。
curl -s -X POST $S/a2a/v1 -H "$H1" -H "$H2" \
-d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"u-1","role":"ROLE_USER","parts":[{"text":"{\"a\": 19, \"b\": 23}"}]}}}'
{
"id": "a8e6c11c-9fc0-4cf6-a0bb-f57d8e1f09e4",
"contextId": "15840914-19ca-4270-9609-68a005286f5b",
"status": {
"state": "TASK_STATE_COMPLETED",
"timestamp": "2026-09-06T05:31:55.698Z"
},
"artifacts": [
{
"artifactId": "71c4fe38-2b66-4aff-ad0f-b0c1049aaf2c",
"name": "計算結果",
"parts": [
{
"text": "19 + 23 = 42"
}
]
}
],
"metadata": {
"a": 19
}
}
(result.task の中身だけを載せ、history は省略しています)
自作の足し算ワーカーの応答と見分けがつきません。result.task に包まれたTask、TASK_STATE_COMPLETED、成果物の parts[].text。SDKサーバも既定でブロッキング(§3.2.2)で、作業時間1.5秒を待ってから完了したTaskを返してきました。
ステップ3: SendStreamingMessageでSSEを受ける
今度は「7 + 8」の依頼({"a": 7, "b": 8})を、結果をまとめて待つSendMessageではなく、途中経過をSSE(Server-Sent Events)で受け取るSendStreamingMessageで送ります。curlに -N を付けているのは、応答をためずに届いた順に表示させるためです。
curl -s -N -X POST $S/a2a/v1 -H "$H1" -H "$H2" \
-d '{"jsonrpc":"2.0","id":2,"method":"SendStreamingMessage","params":{"message":{"messageId":"u-2","role":"ROLE_USER","parts":[{"text":"{\"a\": 7, \"b\": 8}"}]}}}'
data: {"jsonrpc":"2.0","id":2,"result":{"task":{"id":"a999c98a...","status":{"state":"TASK_STATE_SUBMITTED"}, ...}}}
data: {"jsonrpc":"2.0","id":2,"result":{"statusUpdate":{"taskId":"a999c98a...","status":{"state":"TASK_STATE_WORKING", ...}}}}
data: {"jsonrpc":"2.0","id":2,"result":{"artifactUpdate":{"taskId":"a999c98a...","artifact":{"parts":[{"text":"7 + 8 = 15"}]},"lastChunk":true}}}
data: {"jsonrpc":"2.0","id":2,"result":{"statusUpdate":{"taskId":"a999c98a...","status":{"state":"TASK_STATE_COMPLETED", ...}}}}
(各イベントの contextId・history・timestamp などは省略しています)
第2回で自作の足し算ワーカーから受けたSSEと同じ形です。data: の中身はJSON-RPCレスポンスで、リクエストの id がすべてのイベントに付き、最初にTaskの全体、続いて statusUpdate と artifactUpdate が流れます。SSEのラップ形式は仕様(§9)で決まっているとはいえ、自作とSDKが同じ形で読み合えたのは、書いた本人としては安心する瞬間でした。
ここまでの3ステップが「基本の流れ」です。Agent Card、ブロッキング、SSEのどれも、自作の解釈と公式SDKの解釈が一致しました。ここから、仕様が実装に委ねた部分を確かめていきます。
ステップ4: 存在しないtaskIdでGetTaskする
ここからは、仕様が実装に委ねた部分を確かめます。まず、どのタスクにも割り当てられていないID(no-such-task)を指定してGetTaskを送ります。仕様のエラーコード表では、見つからないタスクへの操作は -32001 TaskNotFoundError を返すことになっています。
curl -s -X POST $S/a2a/v1 -H "$H1" -H "$H2" \
-d '{"jsonrpc":"2.0","id":3,"method":"GetTask","params":{"id":"no-such-task"}}'
SDK 1.1.0(2026-08-26公開)の応答です。
{"jsonrpc":"2.0","id":3,"error":{"code":-32001,"message":"Task not found: no-such-task","data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"TASK_NOT_FOUND","domain":"a2a-protocol.org"}]}}
仕様のエラーコード表(§5.4)どおりの -32001 TaskNotFoundError で、error.data には仕様が定める google.rpc.ErrorInfo 形式の詳細(reason と domain)まで付いています。自作の足し算ワーカーは -32001 を返すだけで data は付けていません。ここはSDKのほうが仕様に忠実です。
ところが、同じリクエストを1つ前のバージョンであるSDK 1.0.1(2026-07-28公開)に送ると、こうなります。
{"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Task not found: no-such-task"}}
-32603 はJSON-RPCの汎用エラー「Internal error」です。メッセージ文は "Task not found" なのに、コードが仕様の表と食い違っています。筆者が最初にこの検証をした2026-08-20の時点では1.0.1が最新で、公式SDKが仕様のエラーコード表に従っていないという観察になっていました。この食い違いは1.1.0で直っています。原因は後述します。
ステップ5: A2A-Versionヘッダを付けずに送る
これまで毎回付けてきた A2A-Version: 1.0 ヘッダ($H2)を外して、普通のSendMessage({"a": 1, "b": 2} の依頼)を送ります。第1回で見たとおり、自作ワーカーはこれを -32009 VersionNotSupportedError で断りました。
curl -s -i -X POST $S/a2a/v1 -H "$H1" \
-d '{"jsonrpc":"2.0","id":4,"method":"SendMessage","params":{"message":{"messageId":"u-4","role":"ROLE_USER","parts":[{"text":"{\"a\": 1, \"b\": 2}"}]}}}'
HTTP/1.1 500 Internal Server Error
{"jsonrpc":"2.0","id":4,"error":{"code":-32009,"message":"The requested A2A protocol version '0.3' is not supported. Supported versions: 1.0","data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"VERSION_NOT_SUPPORTED","domain":"a2a-protocol.org"}]}}
自作の足し算ワーカーと同じく -32009 VersionNotSupportedError です。エラーメッセージに注目してください。「要求されたバージョン '0.3' は未対応」と言っています。ヘッダを付けなかったのに '0.3' と解釈されたのは、仕様(§3.6)が「ヘッダが空なら0.3とみなす」と定めているからで、SDKはそれを忠実に実装しています。もう1つ、HTTPステータスが 500 である点も自作(200)と違います。JSON-RPCでは通信自体は成功しているのでHTTP 200でJSON-RPCエラーを返す実装が多いのですが、仕様のエラー表(§5.4)にあるHTTPステータスの列はHTTP/RESTバインディング向けで、JSON-RPCバインディング(§9.5)にはHTTPステータスの規定がありません。ただ、HTTPの決まりでは5xxはサーバ側の障害を表すステータスで、クライアントの要求が原因なら4xxを返すのが筋です。仕様の表もRESTバインディングでは 400 としています。SDKのコードを読むと、バージョンの検証はJSON-RPCの処理に入る前の段階で例外として投げられ、それを受け止める「未処理のエラー」用の経路が一律に500を返す作りになっていました。バージョン違いのために設計された応答ではなく、汎用のエラー経路に落ちた結果です。いずれにせよ、クライアントがHTTPステータスだけを見て成否を判断すると、実装によって結果が変わる箇所です。
ステップ6: messageIdの無いMessageを送る
messageId は仕様上の必須フィールドです(§4.1.4)。自作の足し算ワーカーは検証していないので、無くてもそのまま処理します。SDKはどうでしょうか。
curl -s -X POST $S/a2a/v1 -H "$H1" -H "$H2" \
-d '{"jsonrpc":"2.0","id":5,"method":"SendMessage","params":{"message":{"role":"ROLE_USER","parts":[{"text":"{\"a\": 1, \"b\": 2}"}]}}}'
{"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"message.messageId is required.","data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"INVALID_PARAMS","domain":"a2a-protocol.org"}]}}
SDK 1.1.0は -32602 InvalidParams で弾きました。仕様(§5.4)が想定するとおりのコードです。同じリクエストを1.0.1に送ると、ここも -32603 でした。
{"jsonrpc":"2.0","id":5,"error":{"code":-32603,"message":"message.messageId is required."}}
自作は素通し、SDKは弾く。入力検証の厳しさは仕様ではなく実装が決めていることが分かります。相互運用の場面では「自作クライアントが雑なMessageを送ってもこれまで動いていたのに、相手をSDK製に替えたら弾かれた」という形で現れます。
ステップ7: 完了済みのタスクに追加のMessageを送る
ステップ2で完了したTaskのIDを taskId に付けて、追加のMessageを送ります($TID はステップ2の応答の result.task.id)。
curl -s -X POST $S/a2a/v1 -H "$H1" -H "$H2" \
-d "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"SendMessage\",\"params\":{\"message\":{\"messageId\":\"u-6\",\"taskId\":\"$TID\",\"role\":\"ROLE_USER\",\"parts\":[{\"text\":\"1\"}]}}}"
{"jsonrpc":"2.0","id":6,"error":{"code":-32004,"message":"Task a8e6c11c-9fc0-4cf6-a0bb-f57d8e1f09e4 is in a terminal state (3) and cannot be modified.","data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"UNSUPPORTED_OPERATION","domain":"a2a-protocol.org"}]}}
1.1.0は -32004 UnsupportedOperationError で、自作の足し算ワーカーの判断(第2回のステップ6)と同じコードになりました。1.0.1では例によって -32603 でした。
{"jsonrpc":"2.0","id":6,"error":{"code":-32603,"message":"Task 440e1866-10e5-4575-9f36-fb86574950eb is in a terminal state (3) and cannot be modified."}}
メッセージ文の (3) は、SDKの内部で状態を表している数値(TASK_STATE_COMPLETED = 3)がそのまま漏れたものです。SDKの内部ではenumが文字列ではなく数値で持たれています。この点は「実測して初めて分かったこと」の4で改めて取り上げます。
ステップ8: でたらめなrole値を送る
role に仕様に無い値 "banana" を入れて送ります。
curl -s -X POST $S/a2a/v1 -H "$H1" -H "$H2" \
-d '{"jsonrpc":"2.0","id":7,"method":"SendMessage","params":{"message":{"messageId":"u-7","role":"banana","parts":[{"text":"{\"a\": 1, \"b\": 2}"}]},"configuration":{"returnImmediately":true}}}'
{
"id": "5f15f95e...",
"status": {
"state": "TASK_STATE_SUBMITTED",
"timestamp": "2026-09-06T05:31:57.310Z"
},
"history": [
{
"messageId": "u-7",
"role": "UNRECOGNIZED",
"parts": [
{
"text": "{\"a\": 1, \"b\": 2}"
}
]
}
]
}
(result.task の中身だけを載せ、一部フィールドを省略しています)
弾かれません。受け付けてSUBMITTEDの受領書を返し、そのまま計算まで進みます。2.5秒後にGetTaskすると、完了して成果物もできています。
{
"id": "38cd0cc4...",
"status": {
"state": "TASK_STATE_COMPLETED"
},
"artifacts": [
{
"name": "計算結果",
"parts": [
{
"text": "1 + 2 = 3"
}
]
}
],
"history": [
{
"messageId": "u-7",
"role": "UNRECOGNIZED"
},
"..."
]
}
(result の中身だけを載せ、一部フィールドを省略しています)
注目してほしいのは履歴に残った role です。送った "banana" ではなく "UNRECOGNIZED" になっています。SDKは受け取ったJSONを内部の型(enumは数値)に変換してから扱い、知らない値は UNRECOGNIZED(内部では -1)に置き換えます。それをまたJSONに戻すとこの文字列になります。自作の足し算ワーカーは検証しないので "banana" がそのまま履歴に残ります。どちらも弾かないが、記録に残る値が違う。これも仕様が決めていない部分です。
8ステップの結果をまとめる
| 観点 | 自作の足し算ワーカー | @a2a-js/sdk 1.0.1 | @a2a-js/sdk 1.1.0 | 仕様との照合 |
|---|---|---|---|---|
| Agent CardのETag |
"0.2.0"(version由来) |
W/"…"(内容ハッシュ) |
同左 | §8.6.1が両方を明記 |
| ブロッキングの既定 | 終端または中断まで待つ | 同じ | 同じ | §3.2.2に一致 |
| SSEのラップ形式 |
data: の中にJSON-RPCレスポンス、id を全イベントに付ける |
同一 | 同一 | 完全互換 |
| 存在しないtaskIdのGetTask | -32001 |
-32603 |
-32001 + ErrorInfo |
§5.4は -32001
|
| A2A-Versionヘッダ無し |
-32009(HTTP 200) |
-32009 + ErrorInfo(HTTP 500) |
同左 | §3.6「空なら0.3」に一致 |
| messageId無し | 素通し | -32603 |
-32602 + ErrorInfo |
§5.4は -32602
|
| 終端タスクへの追加Message | -32004 |
-32603(本文に数値enumが漏れる) |
-32004 + ErrorInfo |
明確な規定なし |
| でたらめなrole値 | 素通し("banana" が残る) |
素通し("UNRECOGNIZED" が残る) |
同左 | 検証責任は受け手 |
基本の流れ(上3行)は完全に一致し、差があるのは下5行、つまりエラー応答と入力検証の領域です。
公式SDKクライアントから自作の足し算ワーカーに依頼を送る
この章は「何を確かめるのか」で挙げた通信の②です。①では自作クライアントが送り手でしたが、今度は公式SDKが送り手になります。
向きを逆にします。公式SDKのクライアントで第2回の自作の足し算ワーカーを呼び、SDKが実際にどんなHTTPリクエストを送るのかをワーカー側の通信ログ(wire log)で確かめます。
ステップ9: SDKクライアントで自作の足し算ワーカーを呼び、通信ログを見る
次のコードは、筆者が公式SDKのクライアント機能を使って書いたクライアント(sdk-client-probe.mjs、付録2と同じものです)です。ClientFactory.createFromUrl() にワーカーのURLを渡すだけで、Agent Cardの取得とエンドポイントの選択をSDKがしてくれます。
// 公式SDK(@a2a-js/sdk)のクライアントで自作の足し算ワーカー(第2回の task-server.mjs)を呼ぶ(第5回)
// 目的: SDKクライアントが「実際に何を送るか」をワーカー側のwire logで盗聴し、
// 自作サーバの応答をSDKが解釈できるか(相互運用の逆方向)を確認する
import { Role } from '@a2a-js/sdk';
import { ClientFactory } from '@a2a-js/sdk/client';
import crypto from 'node:crypto';
const WORKER = process.env.WORKER_BASE ?? 'http://localhost:4102';
const textMessage = (text, taskId) => ({
messageId: crypto.randomUUID(),
contextId: '',
taskId: taskId ?? '',
role: Role.ROLE_USER,
parts: [{ content: { $case: 'text', value: text } }],
metadata: undefined,
extensions: [],
referenceTaskIds: [],
});
const factory = new ClientFactory();
const client = await factory.createFromUrl(WORKER); // Agent Card取得 → transport自動選択
console.log('--- B-1: sendMessage(ブロッキング): SDKクライアント → 自作ワーカー ---');
const result = await client.sendMessage({ message: textMessage('{"a": 19, "b": 23}') });
if ('status' in result) {
console.log('結果: Task', result.status.state, '/', result.artifacts?.[0]?.parts?.map((p) => p.content?.value).join(''));
} else {
console.log('結果: Message', result.parts?.map((p) => p.content?.value).join(''));
}
console.log('--- B-2: sendMessageStream(SSE): イベント列をSDKで受ける ---');
for await (const ev of client.sendMessageStream({ message: textMessage('{"a": 7, "b": 8}') })) {
const c = ev.payload?.$case;
const v = ev.payload?.value;
if (c === 'statusUpdate') console.log(' statusUpdate :', v.status?.state);
else if (c === 'artifactUpdate') console.log(' artifactUpdate:', v.artifact?.parts?.map((p) => p.content?.value).join(''));
else console.log(` ${c}`);
}
console.log('--- B-3: INPUT_REQUIRED → getTask → 再開 ---');
const t1 = await client.sendMessage({ message: textMessage('{"a": 19}') });
console.log('中断:', t1.status?.state, '/', t1.status?.message?.parts?.map((p) => p.content?.value).join(''));
const t2 = await client.sendMessage({ message: textMessage('23', t1.id) });
console.log('再開:', t2.status?.state, '/', t2.artifacts?.[0]?.parts?.map((p) => p.content?.value).join(''));
const t3 = await client.getTask({ id: t2.id, historyLength: 0 });
console.log('getTask:', t3.status?.state, '(historyキー:', t3.history?.length ?? 'なし', ')');
ワーカーを起動し、別のターミナルでクライアントを実行します。
node task-server.mjs # ワーカー(第2回)
node sdk-client-probe.mjs # SDKクライアント
--- B-1: sendMessage(ブロッキング): SDKクライアント → 自作ワーカー ---
結果: Task 3 / 19 + 23 = 42
--- B-2: sendMessageStream(SSE): イベント列をSDKで受ける ---
task
statusUpdate : 2
artifactUpdate: 7 + 8 = 15
statusUpdate : 3
--- B-3: INPUT_REQUIRED → getTask → 再開 ---
中断: 6 / a=19 を受け取りました。b はいくつですか?
再開: 3 / 19 + 23 = 42
getTask: 3 (historyキー: 0 )
ブロッキング、SSE、中断と再開、GetTaskのすべてで、SDKクライアントは自作の足し算ワーカーの応答を解釈できました。上の出力で、応答に含まれるTaskの状態(status.state)が「結果: Task 3」「中断: 6」のように数字で表示されています。通信上は "TASK_STATE_COMPLETED" のような文字列で届いているのですが、SDKクライアントがそれを内部の型に変換して返すため、スクリプトが受け取る値は TaskState の数値enum(3 = COMPLETED、6 = INPUT_REQUIRED)になります。これも「実測して初めて分かったこと」の4で取り上げます。
ではSDKは何を送っていたのか。ワーカーの wire.log の先頭を見ます。
[クライアント → サーバ]
GET /.well-known/agent-card.json
host: localhost:4102
a2a-version: 1.0
最初に [SDKクライアント → ワーカー] でAgent Cardを取りに来ています。第3回で受付に実装した「相手のAgent Cardを取ってからエンドポイントを知る」流儀を、SDKも同じ順序で踏んでいます。続く最初のSendMessageです。
[クライアント → サーバ]
POST /a2a/v1
host: localhost:4102
content-type: application/json
a2a-version: 1.0
{"jsonrpc":"2.0","method":"SendMessage","params":{"message":{"messageId":"437311c5-135e-4c70-9b3e-e0e023ae3db4","role":"ROLE_USER","parts":[{"text":"{\"a\": 19, \"b\": 23}"}]},"configuration":{}},"id":1}
ここまでcurlで手書きしてきたJSONと同じ形です。 a2a-version: 1.0 ヘッダ、ProtoJSON形式の本文、UUIDの messageId。違いは configuration: {} という空のオブジェクトを常に付けることと、JSON-RPCの id を末尾に置くことくらいで、意味の違いはありません。第1回から手で書いてきたリクエストが、公式SDKの送るものと同じだった、という確認です。
自作受付から公式SDKサーバに取り次ぐ
この章は「何を確かめるのか」で挙げた通信の③です。ここまでで、①自作クライアントと公式SDKサーバ、②公式SDKクライアントと自作の足し算ワーカー、という1対1の組み合わせはどちらも基本の流れが通ることを確かめました。次は、エージェントが間に立つ2段の構成で、他人の実装を相手にしても取り次ぎが成立するかを確かめます。
第3回では、利用者からの依頼を受付が自作の足し算ワーカーに取り次ぐ2段構成を作りました。この2段構成のうち、後ろ側の自作の足し算ワーカーだけを公式SDKサーバに差し替えます。受付のコードは1行も変えず、環境変数 WORKER_BASE で取り次ぎ先のURLを公式SDKサーバに向けるだけです。受付は第3回で「取り次ぎ先の状態を見て自分の状態を決める」実装をしただけなので、相手が自作かSDK製かで動きが変わらなければ、取り次ぎも中断の伝搬も成立するはずです。
ステップ10: 受付の取り次ぎ先を公式SDKサーバに差し替える
node sdk-calc-server.mjs # 公式SDKサーバ(4104)
WORKER_BASE=http://localhost:4104 node reception-server.mjs # 受付(第3回)。取り次ぎ先を公式SDKサーバに
[利用者 → 受付] に3つの依頼を順に送ります。1つ目は第3回のステップ2と同じ「19 + 23」の依頼({"a": 19, "b": 23})です。2つ目はaだけの依頼({"a": 19})で、取り次ぎ先が「bはいくつですか」と聞き返して中断(INPUT_REQUIRED)になるはずです。3つ目はその受付タスクに対する回答「23」で、再開して完了するはずです。
R=http://localhost:4103
time curl -s -X POST $R/a2a/v1 -H "$H1" -H "$H2" \
-d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"u-1","role":"ROLE_USER","parts":[{"text":"{\"a\": 19, \"b\": 23}"}]}}}'
curl -s -X POST $R/a2a/v1 -H "$H1" -H "$H2" \
-d '{"jsonrpc":"2.0","id":2,"method":"SendMessage","params":{"message":{"messageId":"u-2","role":"ROLE_USER","parts":[{"text":"{\"a\": 19}"}]}}}'
# 応答の result.task.id を $TID に入れて
curl -s -X POST $R/a2a/v1 -H "$H1" -H "$H2" \
-d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"SendMessage\",\"params\":{\"message\":{\"messageId\":\"u-3\",\"taskId\":\"$TID\",\"role\":\"ROLE_USER\",\"parts\":[{\"text\":\"23\"}]}}}"
3つの応答の要点です。
1回目: TASK_STATE_COMPLETED(実測 1.642秒)
2回目: TASK_STATE_INPUT_REQUIRED
状況Message「計算担当からの質問です: a=19 を受け取りました。b はいくつですか?」
3回目: TASK_STATE_COMPLETED
取り次ぎ、中断の伝搬、再開の転送のすべてが、相手が公式SDKサーバでも成立しました。
1回目の [受付 → 利用者] の応答の中身を、成果物と履歴の部分だけ抜き出して見てみます。
{
"status": { "state": "TASK_STATE_COMPLETED" },
"artifacts": [
{
"name": "計算結果",
"parts": [{ "text": "19 + 23 = 42" }],
"metadata": {
"source": "Slow Calc Agent",
"workerTaskId": "a02d75b5-...",
"workerArtifactId": "39438b36-..."
}
}
],
"history": [
{ "role": "ROLE_USER", "parts": [{ "text": "{\"a\": 19, \"b\": 23}" }] },
{ "role": "ROLE_AGENT", "parts": [{ "text": "依頼を計算担当(Slow Calc Agent)に取り次ぎます" }] },
{ "role": "ROLE_AGENT", "parts": [{ "text": "計算担当から結果を受け取りました" }] }
]
}
(result.task のうち成果物と履歴だけを載せ、IDは短く省略しています)
ここで1つ注意があります。成果物(artifacts)の metadata.source、および履歴(history)の状況Messageには、自作の足し算ワーカーの名前である「Slow Calc Agent」が表示されています。受付エージェントの取り次ぎ先は公式SDKサーバ(名前は「SDK Calc Agent」)であるにもかかわらず、今回の計算には関わっていない自作の足し算ワーカーの名前が現れている点に注目してください。これは受付のプログラムがこの名前を固定の文字列として書き込んでいるためで、取り次ぎ先のAgent Cardから名前を読んでいるわけではありません。実際に公式SDKサーバに送っていることは、受付の通信ログ(reception-wire.log)の [受付 → ワーカー] の行に、送信先URLとして POST http://localhost:4104/a2a/v1(ポート4104は公式SDKサーバ)が出ていることで確認できます。
この観察が示しているのは、第1回で触れた「相手のエージェントの中身は見えなくてよい(opaque)」という性質です。受付は、取り次ぎ先がどのSDKで、どんな言語で作られているかを知らず、知る必要もありません。第3回で「取り次ぎ先の状態を見て自分の状態を決める」実装をしただけで、相手を自作の足し算ワーカーから公式SDKサーバに替えても、受付のコードは1行も変えずに同じように動きました。エージェントの中身が見えなくてよいという性質は、相手がどの実装で作られているかについても成り立つということです。
0.3系と1.0系は会話できるか
この章は「何を確かめるのか」で挙げた通信の④です。①〜③は同じv1.0同士の組み合わせでした。最後に、A2Aプロトコル仕様の世代が違う相手と話してみます。
A2Aは v0.3.0(2025-07-30)から v1.0.0(2026-03-12)で破壊的変更を伴う刷新をしました。第1回で座学として整理した主な違いは次のとおりです。
| 観点 | 0.3系 | 1.0系 |
|---|---|---|
| メソッド名 |
message/send / message/stream / tasks/get
|
SendMessage / SendStreamingMessage / GetTask
|
| enumの表記 | 小文字("user" / "completed") |
大文字のプレフィックス付き("ROLE_USER" / "TASK_STATE_COMPLETED") |
| オブジェクトの種別の見分け方 | オブジェクト自身が持つ kind フィールドの値で判別(Messageなら "kind": "message"、テキストのPartなら "kind": "text") |
オブジェクトが置かれているキーの名前で判別(Taskなら result.task、Messageなら result.message、テキストのPartなら {"text": ...})。kind フィールドは無い |
| Agent Cardでの接続先の書き方 | 接続先URLを url に1本書き、通信方式は preferredTransport で別に指定する |
supportedInterfaces[] に「URL・通信方式(protocolBinding)・プロトコルバージョン」の組を並べ、クライアントが話せる組を選ぶ |
| プロトコルバージョンの表明 | Agent Cardの protocolVersion: "0.3.0"
|
Agent Cardの supportedInterfaces[].protocolVersion と、リクエストごとの A2A-Version ヘッダ |
3行目の「種別の見分け方」を補足します。0.3系では、受け取る側は kind の値を見て「これはMessageだ」「このPartはテキストだ」と判断します。1.0系では kind が無くなり、その代わりに「どのキーの下に置かれているか」が種別を表します。ステップ2の応答で result.task の中にTaskが入っていたのがその例で、Messageで返るときは result.message に入ります。この形は、1.0系でデータモデルの正式な定義になったProtocol Buffersの「複数の候補のうち1つだけを持つ」フィールド(oneof)を、JSONに変換する規則から来ています。選ばれた候補のフィールド名がそのままキーになるので、種別を別のフィールドで書く必要がありません。
見比べるために、0.3系のエージェントを実際に立てます。Python公式SDK(a2a-python)の0.3系(a2a-sdk<0.4)で、公式サンプルのhelloworldと同じ形の挨拶エージェントです。
# A2Aプロトコル 0.3系のエージェント(Python公式SDK a2a-sdk 0.3.x 製。公式helloworldサンプルと同じ形)
# 第5回: 0.3系と1.0系の非互換を実測するための相手役
# 用意: python3 -m venv .venv && .venv/bin/pip install 'a2a-sdk[http-server]<0.4' uvicorn && .venv/bin/python legacy-0.3-server.py
import uvicorn
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.types import AgentCard, AgentCapabilities, AgentSkill
from a2a.utils import new_agent_text_message
PORT = 4105
class HelloExecutor(AgentExecutor):
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
await event_queue.enqueue_event(new_agent_text_message('Hello from A2A 0.3'))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise Exception('cancel not supported')
card = AgentCard(
name='Legacy Hello Agent',
description='A2A 0.3系(a2a-sdk 0.3.x)で動く挨拶エージェント。1.0系との非互換を観察する相手役',
url=f'http://localhost:{PORT}/',
version='0.1.0',
default_input_modes=['text'],
default_output_modes=['text'],
capabilities=AgentCapabilities(streaming=True),
skills=[AgentSkill(id='hello', name='挨拶', description='挨拶を返す', tags=['hello'], examples=['hi'])],
)
handler = DefaultRequestHandler(agent_executor=HelloExecutor(), task_store=InMemoryTaskStore())
app = A2AStarletteApplication(agent_card=card, http_handler=handler)
uvicorn.run(app.build(), host='127.0.0.1', port=PORT, log_level='warning')
ステップ11: 0.3系サーバを立てて、Agent Cardを見る
python3 -m venv .venv
.venv/bin/pip install 'a2a-sdk[http-server]<0.4' uvicorn
.venv/bin/python legacy-0.3-server.py
Agent Cardの置き場所は1.0と同じ /.well-known/agent-card.json です(0.2系までは agent.json で、0.3.0で現在のパスに変わりました。a2a-sdk 0.3.26は旧パスにも非推奨として応答します)。
curl -s http://localhost:4105/.well-known/agent-card.json
{
"capabilities": {
"streaming": true
},
"defaultInputModes": [
"text"
],
"defaultOutputModes": [
"text"
],
"description": "A2A 0.3系(a2a-sdk 0.3.x)で動く挨拶エージェント。1.0系との非互換を観察する相手役",
"name": "Legacy Hello Agent",
"preferredTransport": "JSONRPC",
"protocolVersion": "0.3.0",
"skills": [
{
"description": "挨拶を返す",
"examples": [
"hi"
],
"id": "hello",
"name": "挨拶",
"tags": [
"hello"
]
}
],
"url": "http://localhost:4105/",
"version": "0.1.0"
}
先ほどの表の0.3系の列と見比べてください。プロトコルバージョンはAgent Card全体に1つ(protocolVersion: "0.3.0")で、接続先は url に1本、通信方式は preferredTransport で別に書かれています。1.0系の supportedInterfaces はありません。表で整理した0.3系の書き方が、そのまま実物に現れています。
ステップ12: 0.3系サーバに1.0形式と0.3形式で話しかける
まず [curl → 0.3系サーバ] に、ここまで使ってきた1.0形式のSendMessageを送ります。
L=http://localhost:4105
curl -s -X POST $L/ -H "$H1" -H "$H2" \
-d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"u-1","role":"ROLE_USER","parts":[{"text":"hi"}]}}}'
{"error":{"code":-32601,"message":"Method not found"},"id":1,"jsonrpc":"2.0"}
-32601 Method not found。JSON-RPCの層で「そんなメソッドは知らない」と断られ、A2Aの層にすら届きません。次に0.3形式で送ります。
curl -s -X POST $L/ -H "$H1" \
-d '{"jsonrpc":"2.0","id":2,"method":"message/send","params":{"message":{"kind":"message","messageId":"u-2","role":"user","parts":[{"kind":"text","text":"hi"}]}}}'
{"id":2,"jsonrpc":"2.0","result":{"kind":"message","messageId":"b1ccd1fd-7ddb-4635-b101-27c64e5efd25","parts":[{"kind":"text","text":"Hello from A2A 0.3"}],"role":"agent"}}
こちらは通ります。応答も0.3形式で、"kind": "message"、"role": "agent" という表記です。
ステップ13: 1.0系の自作の足し算ワーカーに0.3形式で話しかける
今度は組み合わせを入れ替えます。ステップ12は「0.3系サーバに1.0形式」でしたが、ここでは「1.0系サーバに0.3形式」です。[curl → 第2回の自作の足し算ワーカー] に、ステップ12と同じ0.3形式の依頼を送ります(A2A-Version ヘッダは付けません。0.3系クライアントは付けないからです)。
curl -s -X POST http://localhost:4102/a2a/v1 -H "$H1" \
-d '{"jsonrpc":"2.0","id":2,"method":"message/send","params":{"message":{"kind":"message","messageId":"u-2","role":"user","parts":[{"kind":"text","text":"hi"}]}}}'
{
"jsonrpc": "2.0",
"id": 2,
"error": {
"code": -32009,
"message": "Version not supported"
}
}
-32009 VersionNotSupportedError。ヘッダが無いので0.3の依頼だと解釈し、対応していないと断ります。つまり0.3系と1.0系は、どちらの向きでも最初の1往復で会話が終わる関係です。0.3系サーバは -32601(Method not found)、1.0系サーバは -32009(VersionNotSupportedError)と、断り方まで違います。
この非互換は実運用でも起こり得ます。例えばAWSのエージェントフレームワーク Strands Agents は、本記事執筆時点の最新版(strands-agents 1.54.0、2026-08-27公開)でもA2A機能向けのオプション依存(extras a2a)を a2a-sdk<0.4.0 に固定しています。仕様v1.0の公開から半年近く経っていますが、このフレームワークで今日ビルドしたエージェントは0.3系で話します。「A2A対応」と書かれた製品同士をつないだら、0.3系の側が -32601(Method not found)、1.0系の側が -32009(VersionNotSupportedError)を返して終わり、ということが起こりうるわけです。
この非互換に対処するには、サーバ側を0.3系と1.0系の両方のリクエストを処理できるように作る、という追加の実装が必要です。@a2a-js/sdk を使っているサーバなら、legacyCompat: { enabled: true } というオプションを開発者が有効にすることで、A2A-Version ヘッダが0.3系(または無し)のリクエストを0.3形式として解釈し、応答も0.3形式に変換して返します(互換ガイド)。ただしこれは、SDKがサーバ側で両方の形式を話し分けているだけで、A2Aプロトコル自体にクライアントとサーバがバージョンを合わせる手順があるわけではありません。プロトコルにあるのは「ヘッダが無ければ0.3とみなす」という取り決めだけです。
実測して初めて分かったこと
1. 相互運用の検証により、仕様の誤認識を1件見つけられた
見つかったのは、GetTaskの応答の形についての誤認識です。応答のJSONを3つ並べて説明します。
まず、SendMessageに対する [サーバ → クライアント] の応答です。仕様では、result の中に task または message というキーで包んで返します。TaskかMessageかの二択の応答なので、どちらが入っているかをキー名で示す形です。ステップ2で見た応答がこれです。
{"jsonrpc": "2.0", "id": 1,
"result": { "task": { "id": "a8e6c11c-...", "status": { "state": "TASK_STATE_COMPLETED" }, ... } } }
次に、GetTaskに対する応答の正しい形です。GetTaskの応答は必ずTaskなので二択にはならず、仕様(a2a.proto の rpc GetTask returns (Task))では result の直下にTaskをそのまま置きます。ステップ8の最後で公式SDKサーバにGetTaskしたときの応答がこれです。
{"jsonrpc": "2.0", "id": 8,
"result": { "id": "38cd0cc4-...", "status": { "state": "TASK_STATE_COMPLETED" }, ... } }
そして、第2回を書いた時点の自作の足し算ワーカーが、GetTaskに対して返していた形です。SendMessageの応答と同じ形だと思い込み、task で包んでいました。
{"jsonrpc": "2.0", "id": 8,
"result": { "task": { "id": "...", "status": { ... }, ... } } }
2つ目と3つ目を見比べると、result の直下にTaskがあるか、task というキーが1段挟まるかの違いです。これが表に出たのは、ステップ9で公式SDKクライアントから自作の足し算ワーカーにGetTaskを送ったときでした。SDKクライアントは2つ目の形を期待して応答を読むので、3つ目の形を解釈できずに止まったのです。そこで a2a.proto を読み直して誤認識に気づき、自作の足し算ワーカーを修正しました。
自作サーバ・自作クライアント・自作スクリプトはすべて同じ誤認識のもとで書かれていたので、自作同士では通じてしまい、第2回の実測でも気づけませんでした。別実装と突き合わせて初めて見つかる誤りがある、というのが今回いちばん身に染みた事実です。本シリーズの第2回以降の付録コードは修正済みのものです。
2. 壊れやすいのはエラー応答と入力検証で、基本の流れは壊れない
8ステップの結果をまとめた表を見返すと、自作の足し算ワーカーと公式SDKサーバの応答の差は、存在しないtaskIdへのGetTaskや完了済みタスクへの追加Messageのようなエラー応答と、messageIdの欠落やでたらめなrole値のような不正な入力の扱いに多く見られます。一方、メソッド名・ProtoJSONの形・SSEの形・Taskの状態遷移といったA2Aの基本の流れには差がありませんでした。相互運用を検証するなら、正常系を1回通して安心するのではなく、エラー系と境界値にテスト項目を置くべきだ、ということになります。
3. 公式SDKも仕様と食い違うバージョンがある
ステップ4・6・7で見たとおり、@a2a-js/sdk 1.0.1 は、仕様のエラーコード表では -32001(TaskNotFoundError)・-32602(InvalidParams)・-32004(UnsupportedOperationError)を返すべき場面で、いずれもJSON-RPCの汎用エラー -32603(Internal error)を返していました。ただし、SDKがエラーコードの対応を誤解していたわけではありません。修正のプルリクエスト(a2a-js #612。報告は #608)によると、JavaScriptのライブラリは、多数のソースファイルを配布用に少数のファイルへまとめてから公開するのが一般的で、このまとめたファイルをバンドルと呼びます。@a2a-js/sdk は @a2a-js/sdk/server や @a2a-js/sdk/server/express のようにimport先のパス(サブパス)ごとに1つのバンドルを作る構成で、エラーの基底クラス A2AError の定義が、それぞれのバンドルに別々に複製されていました(付録1のコードも、@a2a-js/sdk・@a2a-js/sdk/server・@a2a-js/sdk/server/express の3つのサブパスから読み込んでいます)。JavaScriptの instanceof は「同じクラス定義から作られたか」を見るので、TaskNotFoundError を投げる処理と、それを instanceof A2AError で判定してJSON-RPCエラーに変換する処理が別のバンドルにあると、複製同士は別のクラスとみなされて判定に失敗し、汎用の -32603(Internal error)に落ちる。原因はJavaScriptのモジュール構成です。「A2A準拠のSDKを使えば安心」ではなく、準拠はSDKのバージョンごとに、しかも実際のリクエストと応答の中身で確かめるものだと分かります。
4. 「正式な定義はProtocol Buffers」の実装的な意味
端的に言うと、SDKは通信で見えるJSONではなく、Protocol Buffersの定義から作った型でデータを持っている、ということです。そのために、JSONだけを見ていては説明できない見え方が3つ出てきました。順に説明します。
第1回で、A2Aのデータの正式な定義はProtocol Buffersの定義ファイル(a2a.proto)で、JSONはそこから機械的に変換した表現だと書きました。@a2a-js/sdk はこれを文字どおりに実装しています。a2a.protoからTypeScriptの型を生成し(ts-protoというコード生成器を使っています)、内部ではその型でデータを持ちます。Protocol Buffersの世界では、enumは "TASK_STATE_COMPLETED" という文字列ではなく 3 という数値です。通信で見慣れたJSONの形は、送信の直前に内部の型から変換して作られ(3 → "TASK_STATE_COMPLETED")、受信した直後に内部の型へ戻されます("TASK_STATE_COMPLETED" → 3)。
この前提が分かると、本記事で出てきた3つの見え方が1つの原因で説明できます。ステップ7のエラー文に (3) が漏れたのは、内部の数値がそのまま文字列に埋め込まれたものです。ステップ8で "banana" が "UNRECOGNIZED" に変わったのは、enumの定義に無い値を受け取ったときに割り当てる決まりの値(UNRECOGNIZED = -1)に置き換えられ、それが再びJSONに戻されたものです。ステップ9でSDKクライアントの出力が 3 や 6 だったのは、SDKクライアントが受信したJSONの "TASK_STATE_COMPLETED" や "TASK_STATE_INPUT_REQUIRED" を内部の数値(3、6)に変換し、スクリプトにはその数値のまま渡しているからです。
一方、自作実装はJSONをそのまま持つので、この変換層がありません。その代わり、知らない値が来てもそのまま通してしまいます(ステップ8で "banana" がそのまま履歴に残ったのがこれです)。
SDKを使ってエージェントを書く側には、この違いは書き方の制約として現れます。enumは "TASK_STATE_SUBMITTED" という文字列ではなく TaskState.TASK_STATE_SUBMITTED と書きます。付録1のコードが TaskState.* を使っているのはそのためです。
5. A2Aには、プロトコルバージョンを合わせる手順が無い
A2Aプロトコルには、MCPの initialize のような「クライアントとサーバがお互いに話せるプロトコルバージョンを確認し合う」手順、いわゆるバージョンネゴシエーション(version negotiation)がありません。あるのは、クライアントが A2A-Version ヘッダで自分のバージョンを申告することと、「ヘッダが無ければ0.3とみなす」という取り決めだけです。申告したバージョンにサーバが対応していなければ、最初の1往復で -32601(Method not found)か -32009(VersionNotSupportedError)が返って終わります。そのためクライアントは、依頼を送る前にAgent Cardの protocolVersion を読んで、自分が話せる相手かを判断する必要があります。第1回で、発見(discovery)がA2Aの入口だと書いた理由が、ここでもう1つ増えました。
MCPとの対比
比較の前提として、本シリーズが対象にしてきたMCPは2025-06-18版です。
| 観点 | A2A(v1.0) | MCP(2025-06-18版) |
|---|---|---|
| プロトコルバージョンの伝え方 | Agent Cardの supportedInterfaces[].protocolVersion で宣言し、リクエストごとに A2A-Version ヘッダを付ける。交渉の手順は無く、ヘッダが無ければ0.3扱い(§3.6) |
initialize でクライアントがプロトコルバージョンを提示し、サーバが対応するバージョンを返す交渉がある(Lifecycle)。HTTPでは以後 MCP-Protocol-Version ヘッダを付ける |
| 大きなバージョンの変わり方 | 0.3→1.0でメソッド名・enum表記・Agent Cardの構造が変わり、同じエンドポイントに旧形式を送ると -32601(Method not found)/ -32009(VersionNotSupportedError) |
2024-11-05→2025-03-26でHTTPのトランスポートが変わった(HTTP+SSE → Streamable HTTP)。メソッド名(tools/call 等)は維持 |
どちらもプロトコルバージョンの違いをHTTPヘッダで表す点は共通です。違いは、MCPには接続の最初にバージョンを合わせる会話(initialize)があり、A2Aにはそれが無いことです。A2Aは接続に状態を持たせない設計なので、初回の交渉という考え方がそもそも入っていません。その代わりに、Agent Cardという「事前に読める自己紹介」にバージョンを書き、クライアントが読んで判断する形になっています。第1回から見てきたAgent Cardの役割が、ここでも設計の要になっています。
まとめ
- 自作実装と公式SDK(@a2a-js/sdk)は、Agent Card・ブロッキング・SSE・Taskの状態遷移という基本の流れで完全に互換だった。第1回から手書きしてきたJSONは、SDKクライアントが送るものと一致していた
- 自作実装と公式SDKの差は、エラー応答と入力検証に多く見られた。例えばmessageIdの無いMessageを、自作は素通しし、SDKは弾く。仕様が実装に委ねた部分の差は、通信相手を別の実装に替えたときに初めて表に出る
- 公式SDK(@a2a-js/sdk)1.0.1は仕様のエラーコード表と食い違っていた。
-32001(TaskNotFoundError)・-32602(InvalidParams)・-32004(UnsupportedOperationError)を返すべき場面で、すべて-32603(Internal error)を返していた。1.1.0で修正済み - 相互運用の検証により、仕様の誤認識(GetTaskの応答を
result.taskに包んでいた)が1件見つかった。自作同士では同じ誤認識のまま通じてしまう - A2Aプロトコル仕様の0.3系と1.0系ではメソッド名の定義自体が異なっており、どちらの形式で送っても最初の1往復でエラーが返って終わる。プロトコルバージョンの交渉手順は無く、Agent Cardの
protocolVersionを先に読んで判断するのはクライアントの責務 - 製品やサービスに「A2A対応」を求めるなら、プロトコルバージョン(1.0系か0.3系か)と、エラーコードを含む検証項目を明示する必要がある。明示しなければ、本記事で見たような実装ごとの差を見過ごしたまま受け入れることになる
シリーズを終えて
5回にわたって、外部ライブラリを使わないNodeサーバとcurl、そして通信ログだけでA2Aを追いかけてきました。振り返ると、毎回の発見は同じ場所から出てきています。仕様を読んで「こうだろう」と実装し、通信ログで実物を見て、「思っていたのと違う」を1つずつ潰す。その積み重ねで、A2Aは「エージェントの自己紹介(Agent Card)」「作業単位(Task)とその状態」「状態の届け方(ポーリング・SSE・webhook)」という少ない部品でできていること、そして部品の少なさの裏で「接続に状態を持たせない」という設計判断が一貫していることが見えてきました。
最終回で自作実装の仕様の誤認識が見つかったのは、シリーズの締めとしてはむしろ良い結末だったと思っています。プロトコルの理解は、自分の実装が動いた時点では完成せず、別の実装と話せた時点でようやく確かめられる。相互運用のためのプロトコルを学ぶとは、そういうことなのでしょう。
付録1: sdk-calc-server.mjs の全文
公式SDK製の足し算エージェントです。npm install @a2a-js/sdk@1.1.0 express の後、node sdk-calc-server.mjs で起動します。
// 公式SDK(@a2a-js/sdk)製の計算エージェント(第5回: 相互運用の検証相手)
//
// ビジネスロジックは自作の足し算ワーカー(第2回の task-server.mjs)と同じ足し算に揃えてある。
// したがって自作実装との挙動差が出たら、それはSDKのプロトコル層(DefaultRequestHandler等)の
// 解釈・実装判断ということになる。ここが観察対象。
//
// 依存: @a2a-js/sdk + express(外部ライブラリを使わない本シリーズの原則の例外)
// 用意: npm init -y && npm install @a2a-js/sdk@1.1.0 express && node sdk-calc-server.mjs
import express from 'express';
import crypto from 'node:crypto';
import { TaskState, Role } from '@a2a-js/sdk';
import { DefaultRequestHandler, InMemoryTaskStore, AgentEvent } from '@a2a-js/sdk/server';
import { agentCardHandler, jsonRpcHandler, UserBuilder } from '@a2a-js/sdk/server/express';
// 注意(実測で判明): SDKの内部表現はts-proto流で、ワイヤのProtoJSONとは別物。
// - enumは数値(TaskState.TASK_STATE_SUBMITTED = 1, Role.ROLE_USER = 1)
// - Partはoneofの $case ラッパー({content: {$case: 'text', value: '...'}})
// 文字列enum('TASK_STATE_SUBMITTED')やフラットなPart({text: '...'})で渡すと
// 変換層で UNRECOGNIZED / 空オブジェクトになる。ProtoJSONへの変換はトランスポート層の仕事
const PORT = 4104;
const BASE = `http://localhost:${PORT}`;
const WORK_MS = Number(process.env.WORK_MS ?? 1500);
const agentCard = {
name: 'SDK Calc Agent',
description: '公式SDK(@a2a-js/sdk)で実装した足し算エージェント。自作実装との相互運用検証用',
version: '0.1.0',
supportedInterfaces: [
{ url: `${BASE}/a2a/v1`, protocolBinding: 'JSONRPC', protocolVersion: '1.0' },
],
capabilities: { streaming: true, pushNotifications: false, extendedAgentCard: false },
defaultInputModes: ['application/json', 'text/plain'],
defaultOutputModes: ['text/plain'],
skills: [
{
id: 'sdk-add',
name: 'SDKで足し算',
description: '2つの数値を足し算する。bが無ければ聞き返す',
tags: ['calculator', 'add'],
examples: ['{"a": 19, "b": 23}', '19 + 23'],
inputModes: ['application/json', 'text/plain'],
outputModes: ['text/plain'],
},
],
};
// 入力解釈は自作の足し算ワーカーと同一
function parseNumbers(text) {
try {
const o = JSON.parse(text);
if (o && typeof o === 'object') {
return { a: typeof o.a === 'number' ? o.a : undefined, b: typeof o.b === 'number' ? o.b : undefined };
}
} catch { /* 文章として解釈 */ }
const nums = [...text.matchAll(/-?\d+(?:\.\d+)?/g)].map((m) => Number(m[0]));
return { a: nums[0], b: nums[1] };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const textPart = (text) => ({ content: { $case: 'text', value: text } });
const partText = (p) => (p?.content?.$case === 'text' ? p.content.value : '');
function agentMessage(text, taskId, contextId) {
return { messageId: crypto.randomUUID(), taskId, contextId, role: Role.ROLE_AGENT, parts: [textPart(text)] };
}
function status(state, message) {
return { state, timestamp: new Date().toISOString(), ...(message ? { message } : {}) };
}
// AgentExecutor の実装。SDKが依頼(SendMessage)を受けるたびに execute() を呼ぶ。
// ここではイベントをバスに流すだけでよく、ブロッキング/SSE/タスク保存/エラー応答は全部SDK(DefaultRequestHandler)の仕事
const executor = {
async execute(ctx, bus) {
const text = (ctx.userMessage.parts ?? []).map(partText).join('');
const input = parseNumbers(text);
const prior = ctx.task; // 再開ターンならSDKが既存Taskを渡してくる
if (!prior) {
// 最初のイベントはtask(SDKの規約)。aは再開時に使うためmetadataに残す
bus.publish(AgentEvent.task({
id: ctx.taskId,
contextId: ctx.contextId,
status: status(TaskState.TASK_STATE_SUBMITTED),
history: [ctx.userMessage],
artifacts: [],
metadata: { a: input.a },
}));
if (input.a === undefined) {
bus.publish(AgentEvent.statusUpdate({
taskId: ctx.taskId,
contextId: ctx.contextId,
status: status(TaskState.TASK_STATE_REJECTED, agentMessage('数値が見つからないため、この依頼はお受けできません', ctx.taskId, ctx.contextId)),
}));
return bus.finished();
}
if (input.b === undefined) {
bus.publish(AgentEvent.statusUpdate({
taskId: ctx.taskId,
contextId: ctx.contextId,
status: status(TaskState.TASK_STATE_INPUT_REQUIRED, agentMessage(`a=${input.a} を受け取りました。b はいくつですか?`, ctx.taskId, ctx.contextId)),
}));
return bus.finished();
}
} else {
bus.publish(AgentEvent.task(prior)); // 再開ターンでも最初のイベントはtask(SDKの規約)
}
const a = prior ? Number(prior.metadata?.a) : input.a;
const b = prior ? (input.b ?? input.a) : input.b;
bus.publish(AgentEvent.statusUpdate({
taskId: ctx.taskId,
contextId: ctx.contextId,
status: status(TaskState.TASK_STATE_WORKING, agentMessage(`${a} + ${b} の計算を始めます`, ctx.taskId, ctx.contextId)),
}));
await sleep(WORK_MS);
bus.publish(AgentEvent.artifactUpdate({
taskId: ctx.taskId,
contextId: ctx.contextId,
artifact: { artifactId: crypto.randomUUID(), name: '計算結果', parts: [textPart(`${a} + ${b} = ${a + b}`)] },
append: false,
lastChunk: true,
}));
bus.publish(AgentEvent.statusUpdate({
taskId: ctx.taskId,
contextId: ctx.contextId,
status: status(TaskState.TASK_STATE_COMPLETED),
}));
bus.finished();
},
async cancelTask(taskId, bus) {
bus.publish(AgentEvent.statusUpdate({
taskId,
contextId: '',
status: status(TaskState.TASK_STATE_CANCELED),
}));
bus.finished();
},
};
const handler = new DefaultRequestHandler(agentCard, new InMemoryTaskStore(), executor);
const app = express();
app.use('/.well-known/agent-card.json', agentCardHandler({ agentCardProvider: handler }));
app.use('/a2a/v1', jsonRpcHandler({ requestHandler: handler, userBuilder: UserBuilder.noAuthentication }));
app.listen(PORT, () => {
console.log(`SDK Calc Agent (@a2a-js/sdk) : ${BASE}`);
console.log(` Agent Card : ${BASE}/.well-known/agent-card.json`);
console.log(` JSON-RPC : POST ${BASE}/a2a/v1`);
});
付録2: sdk-client-probe.mjs の全文
公式SDKクライアントから自作の足し算ワーカーを呼ぶスクリプトです(ステップ9)。付録1と同じディレクトリに置けば依存はそのまま使えます。
// 公式SDK(@a2a-js/sdk)のクライアントで自作の足し算ワーカー(第2回の task-server.mjs)を呼ぶ(第5回)
// 目的: SDKクライアントが「実際に何を送るか」をワーカー側のwire logで盗聴し、
// 自作サーバの応答をSDKが解釈できるか(相互運用の逆方向)を確認する
import { Role } from '@a2a-js/sdk';
import { ClientFactory } from '@a2a-js/sdk/client';
import crypto from 'node:crypto';
const WORKER = process.env.WORKER_BASE ?? 'http://localhost:4102';
const textMessage = (text, taskId) => ({
messageId: crypto.randomUUID(),
contextId: '',
taskId: taskId ?? '',
role: Role.ROLE_USER,
parts: [{ content: { $case: 'text', value: text } }],
metadata: undefined,
extensions: [],
referenceTaskIds: [],
});
const factory = new ClientFactory();
const client = await factory.createFromUrl(WORKER); // Agent Card取得 → transport自動選択
console.log('--- B-1: sendMessage(ブロッキング): SDKクライアント → 自作ワーカー ---');
const result = await client.sendMessage({ message: textMessage('{"a": 19, "b": 23}') });
if ('status' in result) {
console.log('結果: Task', result.status.state, '/', result.artifacts?.[0]?.parts?.map((p) => p.content?.value).join(''));
} else {
console.log('結果: Message', result.parts?.map((p) => p.content?.value).join(''));
}
console.log('--- B-2: sendMessageStream(SSE): イベント列をSDKで受ける ---');
for await (const ev of client.sendMessageStream({ message: textMessage('{"a": 7, "b": 8}') })) {
const c = ev.payload?.$case;
const v = ev.payload?.value;
if (c === 'statusUpdate') console.log(' statusUpdate :', v.status?.state);
else if (c === 'artifactUpdate') console.log(' artifactUpdate:', v.artifact?.parts?.map((p) => p.content?.value).join(''));
else console.log(` ${c}`);
}
console.log('--- B-3: INPUT_REQUIRED → getTask → 再開 ---');
const t1 = await client.sendMessage({ message: textMessage('{"a": 19}') });
console.log('中断:', t1.status?.state, '/', t1.status?.message?.parts?.map((p) => p.content?.value).join(''));
const t2 = await client.sendMessage({ message: textMessage('23', t1.id) });
console.log('再開:', t2.status?.state, '/', t2.artifacts?.[0]?.parts?.map((p) => p.content?.value).join(''));
const t3 = await client.getTask({ id: t2.id, historyLength: 0 });
console.log('getTask:', t3.status?.state, '(historyキー:', t3.history?.length ?? 'なし', ')');
付録3: try-sdk.sh の全文
ステップ1〜8をまとめたスクリプトです。
#!/bin/bash
# 公式SDK(@a2a-js/sdk)製サーバに、自作クライアント(curl)から仕様が実装に委ねた部分を確かめる8ステップ
# 使い方: node sdk-calc-server.mjs を起動してから bash try-sdk.sh 2>&1 | tee -a logs/try-sdk-run.log
S=${SDK_BASE:-http://localhost:4104}
H1='Content-Type: application/json'
H2='A2A-Version: 1.0'
rpc() { # $1=見出し $2=JSON本文 $3以降=追加curlオプション
echo; echo "### $1"; echo "--- request ---"; echo "$2"
echo "--- response ---"
curl -s -i -X POST $S/a2a/v1 -H "$H1" "${@:3}" -d "$2"; echo
}
echo "===== ステップ1: Agent Card取得(ヘッダに注目) ====="
curl -s -i $S/.well-known/agent-card.json | sed -n '1,12p'; echo "..."
echo
echo "===== ステップ2: SendMessage(ブロッキング) ====="
R2=$(curl -s -X POST $S/a2a/v1 -H "$H1" -H "$H2" -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"messageId":"u-1","role":"ROLE_USER","parts":[{"text":"{\"a\": 19, \"b\": 23}"}]}}}')
echo "$R2"
TID=$(echo "$R2" | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["task"]["id"])')
echo "(taskId=$TID)"
echo
echo "===== ステップ3: SendStreamingMessage(SSE) ====="
curl -s -N -X POST $S/a2a/v1 -H "$H1" -H "$H2" -d '{"jsonrpc":"2.0","id":2,"method":"SendStreamingMessage","params":{"message":{"messageId":"u-2","role":"ROLE_USER","parts":[{"text":"{\"a\": 7, \"b\": 8}"}]}}}'
echo
rpc "ステップ4: 存在しないtaskIdでGetTask(仕様§5.4は -32001 TaskNotFoundError)" \
'{"jsonrpc":"2.0","id":3,"method":"GetTask","params":{"id":"no-such-task"}}' -H "$H2"
rpc "ステップ5: A2A-Versionヘッダ無しでSendMessage(仕様は -32009 VersionNotSupportedError)" \
'{"jsonrpc":"2.0","id":4,"method":"SendMessage","params":{"message":{"messageId":"u-4","role":"ROLE_USER","parts":[{"text":"{\"a\": 1, \"b\": 2}"}]}}}'
rpc "ステップ6: messageId無しのMessage(仕様は必須フィールド。-32602 InvalidParams想定)" \
'{"jsonrpc":"2.0","id":5,"method":"SendMessage","params":{"message":{"role":"ROLE_USER","parts":[{"text":"{\"a\": 1, \"b\": 2}"}]}}}' -H "$H2"
rpc "ステップ7: 完了済みタスクへの追加Message(自作は -32004 を返す)" \
"{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"SendMessage\",\"params\":{\"message\":{\"messageId\":\"u-6\",\"taskId\":\"$TID\",\"role\":\"ROLE_USER\",\"parts\":[{\"text\":\"1\"}]}}}" -H "$H2"
rpc "ステップ8: でたらめなrole値(\"banana\")" \
'{"jsonrpc":"2.0","id":7,"method":"SendMessage","params":{"message":{"messageId":"u-7","role":"banana","parts":[{"text":"{\"a\": 1, \"b\": 2}"}]},"configuration":{"returnImmediately":true}}}' -H "$H2"
付録4: legacy-0.3-server.py の全文
0.3系のエージェントです(ステップ11〜12)。Python公式SDKの0.3系を使います。
# A2Aプロトコル 0.3系のエージェント(Python公式SDK a2a-sdk 0.3.x 製。公式helloworldサンプルと同じ形)
# 第5回: 0.3系と1.0系の非互換を実測するための相手役
# 用意: python3 -m venv .venv && .venv/bin/pip install 'a2a-sdk[http-server]<0.4' uvicorn && .venv/bin/python legacy-0.3-server.py
import uvicorn
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.types import AgentCard, AgentCapabilities, AgentSkill
from a2a.utils import new_agent_text_message
PORT = 4105
class HelloExecutor(AgentExecutor):
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
await event_queue.enqueue_event(new_agent_text_message('Hello from A2A 0.3'))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise Exception('cancel not supported')
card = AgentCard(
name='Legacy Hello Agent',
description='A2A 0.3系(a2a-sdk 0.3.x)で動く挨拶エージェント。1.0系との非互換を観察する相手役',
url=f'http://localhost:{PORT}/',
version='0.1.0',
default_input_modes=['text'],
default_output_modes=['text'],
capabilities=AgentCapabilities(streaming=True),
skills=[AgentSkill(id='hello', name='挨拶', description='挨拶を返す', tags=['hello'], examples=['hi'])],
)
handler = DefaultRequestHandler(agent_executor=HelloExecutor(), task_store=InMemoryTaskStore())
app = A2AStarletteApplication(agent_card=card, http_handler=handler)
uvicorn.run(app.build(), host='127.0.0.1', port=PORT, log_level='warning')