環境構築 — Antigravity + AgentKit 2.0
AntigravityとAgentKit 2.0を使ってA2A準拠エージェントを開発する環境を整えます。
# プロジェクト初期化
mkdir a2a-agent-system && cd a2a-agent-system
npm init -y
# AgentKit 2.0 と関連パッケージのインストール
npm install @google/agentkit@^2.0 @google/genai zod hono
# 開発用ツール
npm install -D typescript @types/node tsx nodemon
tsconfig.json の設定:
{
"compilerOptions" : {
"target" : "ES2022" ,
"module" : "NodeNext" ,
"moduleResolution" : "NodeNext" ,
"strict" : true ,
"outDir" : "./dist" ,
"rootDir" : "./src" ,
"esModuleInterop" : true
}
}
Antigravityのエージェント開発では、プロジェクトルートに .antigravity/rules.md を作成することで、A2A仕様への準拠ルールをコード補完に反映できます:
# A2Aエージェント開発ルール
- すべてのエージェントは /.well-known/agent-card.json を公開すること
- タスクIDはUUID v4を使用すること
- エラーレスポンスはA2A仕様のJSONRPCエラー形式に従うこと
- ストリーミングはSSE(Server-Sent Events)で実装すること
- 認証はBearer Token(JWT)を使用すること
エージェントカードの設計と実装
実際のA2Aエージェントを実装していきます。ここでは「コードレビューエージェント」を例に説明します。
エージェントカードの実装
// src/agent-card.ts
import { z } from "zod" ;
// エージェントカードのスキーマ定義
const AgentSkillSchema = z. object ({
id: z. string (),
name: z. string (),
description: z. string (),
tags: z. array (z. string ()),
inputModes: z. array (z. enum ([ "text" , "file" , "data" ])),
outputModes: z. array (z. enum ([ "text" , "file" , "data" ])),
examples: z. array (z. object ({
name: z. string (),
description: z. string (),
input: z. record (z. unknown ()),
})). optional (),
});
export const CODE_REVIEW_AGENT_CARD = {
name: "CodeReviewAgent" ,
description: "コードをレビューし、品質改善の提案を行うAIエージェント" ,
url: process.env. AGENT_BASE_URL ?? "http://localhost:3001" ,
version: "1.2.0" ,
documentationUrl: "https://your-docs.example.com/agents/code-review" ,
capabilities: {
streaming: true ,
pushNotifications: true ,
stateTransitionHistory: true ,
},
authentication: {
schemes: [ "Bearer" ],
},
defaultInputMode: "text" ,
defaultOutputMode: "text" ,
skills: [
{
id: "review-code" ,
name: "コードレビュー" ,
description: "TypeScript/JavaScript/Pythonコードの品質レビューと改善提案" ,
tags: [ "code-review" , "quality" , "typescript" , "python" ],
inputModes: [ "text" , "file" ],
outputModes: [ "text" , "data" ],
examples: [
{
name: "TypeScript関数レビュー" ,
description: "非同期関数のエラーハンドリングをレビューする" ,
input: { message: "この関数のエラーハンドリングを改善してください" },
},
],
},
{
id: "security-scan" ,
name: "セキュリティスキャン" ,
description: "一般的なセキュリティ脆弱性(XSS, SQLインジェクション等)の検出" ,
tags: [ "security" , "vulnerability" , "owasp" ],
inputModes: [ "text" , "file" ],
outputModes: [ "text" , "data" ],
},
],
} satisfies AgentCard ;
// エージェントカード型定義
export type AgentCard = {
name : string ;
description : string ;
url : string ;
version : string ;
documentationUrl ?: string ;
capabilities : {
streaming : boolean ;
pushNotifications : boolean ;
stateTransitionHistory : boolean ;
};
authentication : {
schemes : string [];
};
defaultInputMode : string ;
defaultOutputMode : string ;
skills : z . infer < typeof AgentSkillSchema>[];
};
Honoサーバーへの統合
// src/server.ts
import { Hono } from "hono" ;
import { cors } from "hono/cors" ;
import { bearerAuth } from "hono/bearer-auth" ;
import { CODE_REVIEW_AGENT_CARD } from "./agent-card" ;
import { taskRouter } from "./routes/tasks" ;
const app = new Hono ();
// CORS設定(A2Aクライアントからのクロスオリジンリクエストを許可)
app. use ( "*" , cors ({
origin: "*" ,
allowMethods: [ "GET" , "POST" ],
allowHeaders: [ "Content-Type" , "Authorization" ],
}));
// エージェントカードの公開(認証不要)
app. get ( "/.well-known/agent-card.json" , ( c ) => {
return c. json ( CODE_REVIEW_AGENT_CARD );
});
// タスクエンドポイント(認証必須)
app. use ( "/tasks/*" , bearerAuth ({ token: process.env. AGENT_TOKEN ! }));
app. route ( "/tasks" , taskRouter);
export default app;
タスク委譲パターンの実装
A2Aの真価はタスクの委譲にあります。3つの主要パターンを実装します。
パターン1: 同期タスク
処理が短時間(数秒以内)で完了する場合の実装です。
// src/routes/tasks.ts
import { Hono } from "hono" ;
import { z } from "zod" ;
import { GoogleGenerativeAI } from "@google/genai" ;
import { v4 as uuidv4 } from "uuid" ;
const taskRouter = new Hono ();
const genai = new GoogleGenerativeAI (process.env. GOOGLE_API_KEY ! );
// タスク送信スキーマ
const SendTaskSchema = z. object ({
jsonrpc: z. literal ( "2.0" ),
id: z. string (). or (z. number ()),
method: z. literal ( "tasks/send" ),
params: z. object ({
id: z. string (). uuid (),
message: z. object ({
role: z. literal ( "user" ),
parts: z. array (z. object ({
type: z. enum ([ "text" , "file" , "data" ]),
text: z. string (). optional (),
file: z. object ({
name: z. string (),
mimeType: z. string (),
bytes: z. string (), // base64
}). optional (),
})),
}),
skillId: z. string (). optional (),
}),
});
// tasks/send ハンドラ(同期)
taskRouter. post ( "/send" , async ( c ) => {
const body = await c.req. json ();
const parsed = SendTaskSchema. safeParse (body);
if ( ! parsed.success) {
return c. json ({
jsonrpc: "2.0" ,
id: body.id ?? null ,
error: {
code: - 32600 ,
message: "Invalid Request" ,
data: parsed.error. flatten (),
},
}, 400 );
}
const { params } = parsed.data;
const taskId = params.id;
const userMessage = params.message.parts
. filter ( p => p.type === "text" )
. map ( p => p.text)
. join ( " \n " );
try {
const model = genai. getGenerativeModel ({ model: "gemini-2.5-pro" });
const result = await model. generateContent ([
{
role: "user" ,
parts: [{
text: `あなたはプロのコードレビュアーです。以下のコードを分析してください: \n\n ${ userMessage }` ,
}],
},
]);
const responseText = result.response. text ();
return c. json ({
jsonrpc: "2.0" ,
id: parsed.data.id,
result: {
id: taskId,
sessionId: uuidv4 (),
status: {
state: "completed" ,
message: {
role: "agent" ,
parts: [{ type: "text" , text: responseText }],
},
timestamp: new Date (). toISOString (),
},
artifacts: [
{
name: "review-result" ,
description: "コードレビュー結果" ,
parts: [{ type: "text" , text: responseText }],
},
],
},
});
} catch (error) {
return c. json ({
jsonrpc: "2.0" ,
id: parsed.data.id,
error: {
code: - 32603 ,
message: "Internal error" ,
data: error instanceof Error ? error.message : "Unknown error" ,
},
}, 500 );
}
});
export { taskRouter };
パターン2: ストリーミングタスク
長時間かかるタスクでリアルタイムに結果を返す場合の実装です。SSE(Server-Sent Events)を使います。
// ストリーミングエンドポイント
taskRouter. post ( "/sendSubscribe" , async ( c ) => {
const body = await c.req. json ();
const taskId = body.params?.id ?? uuidv4 ();
// SSEレスポンスのセットアップ
const stream = new ReadableStream ({
async start ( controller ) {
const encoder = new TextEncoder ();
const sendEvent = ( data : unknown ) => {
const payload = `data: ${ JSON . stringify ( data ) } \n\n ` ;
controller. enqueue (encoder. encode (payload));
};
try {
// 作業開始を通知
sendEvent ({
jsonrpc: "2.0" ,
id: body.id,
result: {
id: taskId,
status: {
state: "working" ,
message: {
role: "agent" ,
parts: [{ type: "text" , text: "コードを解析中..." }],
},
timestamp: new Date (). toISOString (),
},
},
});
const model = genai. getGenerativeModel ({ model: "gemini-2.5-pro" });
const userMessage = body.params?.message?.parts
?. filter (( p : { type : string }) => p.type === "text" )
. map (( p : { text : string }) => p.text)
. join ( " \n " ) ?? "" ;
// ストリーミング生成
const streamResult = await model. generateContentStream ([
{
role: "user" ,
parts: [{ text: `コードをレビューしてください: \n\n ${ userMessage }` }],
},
]);
let fullText = "" ;
let chunkIndex = 0 ;
for await ( const chunk of streamResult.stream) {
const chunkText = chunk. text ();
fullText += chunkText;
// 中間アーティファクトを送信
sendEvent ({
jsonrpc: "2.0" ,
id: body.id,
result: {
id: taskId,
status: {
state: "working" ,
timestamp: new Date (). toISOString (),
},
artifact: {
name: "review-in-progress" ,
index: chunkIndex ++ ,
append: chunkIndex > 1 ,
lastChunk: false ,
parts: [{ type: "text" , text: chunkText }],
},
},
});
}
// 完了を通知
sendEvent ({
jsonrpc: "2.0" ,
id: body.id,
result: {
id: taskId,
status: {
state: "completed" ,
timestamp: new Date (). toISOString (),
},
artifact: {
name: "review-complete" ,
index: chunkIndex,
lastChunk: true ,
parts: [{ type: "text" , text: "" }], // 最終チャンクは空
},
},
});
controller. close ();
} catch (error) {
sendEvent ({
jsonrpc: "2.0" ,
id: body.id,
error: {
code: - 32603 ,
message: error instanceof Error ? error.message : "Stream error" ,
},
});
controller. close ();
}
},
});
return new Response (stream, {
headers: {
"Content-Type" : "text/event-stream" ,
"Cache-Control" : "no-cache" ,
"Connection" : "keep-alive" ,
},
});
});
A2Aクライアントの実装 — 別エージェントからの呼び出し
エージェントが別のエージェントを呼び出す側(クライアント側)の実装です。これによって真のマルチエージェントシステムが完成します。
// src/a2a-client.ts
export class A2AClient {
private agentUrl : string ;
private token : string ;
constructor ( agentUrl : string , token : string ) {
this .agentUrl = agentUrl. replace ( / \/ $ / , "" );
this .token = token;
}
/** エージェントカードを取得して能力を確認する */
async getAgentCard () {
const res = await fetch ( `${ this . agentUrl }/.well-known/agent-card.json` );
if ( ! res.ok) throw new Error ( `Agent card fetch failed: ${ res . status }` );
return res. json ();
}
/** 同期タスク送信 */
async sendTask ( params : {
taskId : string ;
message : string ;
skillId ?: string ;
}) {
const body = {
jsonrpc: "2.0" ,
id: crypto. randomUUID (),
method: "tasks/send" ,
params: {
id: params.taskId,
message: {
role: "user" ,
parts: [{ type: "text" , text: params.message }],
},
skillId: params.skillId,
},
};
const res = await fetch ( `${ this . agentUrl }/tasks/send` , {
method: "POST" ,
headers: {
"Content-Type" : "application/json" ,
Authorization: `Bearer ${ this . token }` ,
},
body: JSON . stringify (body),
});
if ( ! res.ok) {
const err = await res. json (). catch (() => ({}));
throw new Error ( `Task failed: ${ res . status } - ${ JSON . stringify ( err ) }` );
}
const result = await res. json ();
if (result.error) {
throw new Error ( `A2A Error ${ result . error . code }: ${ result . error . message }` );
}
return result.result;
}
/** ストリーミングタスク送信(非同期イテレータを返す) */
async * sendTaskStream ( params : {
taskId : string ;
message : string ;
}) : AsyncGenerator < unknown > {
const body = {
jsonrpc: "2.0" ,
id: crypto. randomUUID (),
method: "tasks/sendSubscribe" ,
params: {
id: params.taskId,
message: {
role: "user" ,
parts: [{ type: "text" , text: params.message }],
},
},
};
const res = await fetch ( `${ this . agentUrl }/tasks/sendSubscribe` , {
method: "POST" ,
headers: {
"Content-Type" : "application/json" ,
Authorization: `Bearer ${ this . token }` ,
},
body: JSON . stringify (body),
});
if ( ! res.ok || ! res.body) throw new Error ( "Stream init failed" );
const reader = res.body. getReader ();
const decoder = new TextDecoder ();
let buffer = "" ;
while ( true ) {
const { done , value } = await reader. read ();
if (done) break ;
buffer += decoder. decode (value, { stream: true });
const lines = buffer. split ( " \n\n " );
buffer = lines. pop () ?? "" ;
for ( const line of lines) {
if (line. startsWith ( "data: " )) {
const json = JSON . parse (line. slice ( 6 ));
yield json;
}
}
}
}
}
// 使用例:オーケストレーターエージェントからコードレビューエージェントを呼ぶ
async function orchestrateCodeReview ( code : string ) {
const client = new A2AClient (
"https://code-review-agent.example.com" ,
process.env. AGENT_TOKEN !
);
// エージェントの能力を確認
const card = await client. getAgentCard ();
console. log ( `接続先エージェント: ${ card . name } v${ card . version }` );
console. log ( `対応スキル: ${ card . skills . map (( s : { name : string }) => s . name ). join ( ", " ) }` );
// ストリーミングでコードレビューを実行
const taskId = crypto. randomUUID ();
process.stdout. write ( "レビュー結果: " );
for await ( const event of client. sendTaskStream ({ taskId, message: code })) {
const result = (event as { result ?: { artifact ?: { parts ?: Array <{ type : string ; text : string }> } } }).result;
if (result?.artifact?.parts) {
for ( const part of result.artifact.parts) {
if (part.type === "text" && part.text) {
process.stdout. write (part.text);
}
}
}
}
console. log ( " \n ✅ レビュー完了" );
}
認証とセキュリティの実装
本番環境では、エージェント間通信のセキュリティが不可欠です。
JWTベースの認証
// src/auth.ts
import { SignJWT, jwtVerify } from "jose" ;
const SECRET = new TextEncoder (). encode (process.env. JWT_SECRET ! );
/** エージェント間通信用のトークンを発行する */
export async function issueAgentToken ( params : {
issuerId : string ; // 発行元エージェントID
targetId : string ; // 宛先エージェントID
scopes : string []; // 許可する操作
expiresIn ?: number ; // 有効期限(秒)デフォルト3600
}) : Promise < string > {
return new SignJWT ({
iss: params.issuerId,
aud: params.targetId,
scopes: params.scopes,
})
. setProtectedHeader ({ alg: "HS256" })
. setIssuedAt ()
. setExpirationTime ( `${ params . expiresIn ?? 3600 }s` )
. sign ( SECRET );
}
/** トークンを検証し、スコープをチェックする */
export async function verifyAgentToken (
token : string ,
requiredScope : string ,
audienceId : string
) : Promise <{ issuerId : string ; scopes : string [] }> {
const { payload } = await jwtVerify (token, SECRET , {
audience: audienceId,
});
const scopes = (payload.scopes as string []) ?? [];
if ( ! scopes. includes (requiredScope)) {
throw new Error ( `Insufficient scope. Required: ${ requiredScope }` );
}
return { issuerId: payload.iss as string , scopes };
}
レートリミットの実装
// src/middleware/rate-limit.ts
import { MiddlewareHandler } from "hono" ;
// シンプルなインメモリレートリミッター(本番ではRedis/Upstashを推奨)
const requestCounts = new Map < string , { count : number ; resetAt : number }>();
export const rateLimitMiddleware = (
maxRequests = 60 ,
windowMs = 60_000
) : MiddlewareHandler => {
return async ( c , next ) => {
const clientId = c.req. header ( "Authorization" )?. slice ( 7 ) ?? c.req. header ( "X-Agent-ID" ) ?? "anonymous" ;
const now = Date. now ();
const record = requestCounts. get (clientId);
if ( ! record || now > record.resetAt) {
requestCounts. set (clientId, { count: 1 , resetAt: now + windowMs });
c. header ( "X-RateLimit-Limit" , String (maxRequests));
c. header ( "X-RateLimit-Remaining" , String (maxRequests - 1 ));
return next ();
}
if (record.count >= maxRequests) {
return c. json ({
jsonrpc: "2.0" ,
id: null ,
error: {
code: - 32000 ,
message: "Rate limit exceeded" ,
data: { retryAfter: Math. ceil ((record.resetAt - now) / 1000 ) },
},
}, 429 );
}
record.count ++ ;
c. header ( "X-RateLimit-Limit" , String (maxRequests));
c. header ( "X-RateLimit-Remaining" , String (maxRequests - record.count));
return next ();
};
};
エラーハンドリングとリトライ戦略
A2Aシステムでの堅牢なエラーハンドリングは、本番運用の鍵となります。
A2A標準エラーコード
A2Aプロトコルは以下のJSONRPCエラーコードを標準化しています:
-32700 : Parse error(JSONパース失敗)
-32600 : Invalid Request(必須フィールド欠落)
-32601 : Method not found(未対応のメソッド)
-32602 : Invalid params(パラメータ不正)
-32603 : Internal error(サーバー内部エラー)
-32001 : Task not found(指定IDのタスクなし)
-32002 : Task not cancelable(キャンセル不可能なタスク)
-32003 : Push notification not supported(プッシュ通知非対応)
-32004 : Unsupported operation(非対応オペレーション)
-32005 : Incompatible content types(非対応コンテンツタイプ)
数値コードだけで分岐を書いていると、v1.0 で少し具合が悪くなります。v1.0 の変更点 によれば、エラーの中身が google.rpc.Status 準拠になり、data(HTTP+JSON では details)の配列に google.rpc.ErrorInfo を必ず含めることになりました。reason には TASK_NOT_FOUND のような大文字スネークケースの識別子が、domain には "a2a-protocol.org" が入ります。
"error" : {
"code" : -32001 ,
"message" : "Task not found" ,
"data" : [
{
"@type" : "type.googleapis.com/google.rpc.ErrorInfo" ,
"reason" : "TASK_NOT_FOUND" ,
"domain" : "a2a-protocol.org" ,
"metadata" : { "taskId" : "123" }
}
]
}
分岐を書くなら reason を先に見て、読めなければ数値コードへ落とすのが実務的です。数値は JSON-RPC バインディング固有の値ですが、reason は三つのバインディングを通して同じ言葉で返ってきます。
指数バックオフ付きリトライクライアント
// src/retry-client.ts
type RetryOptions = {
maxAttempts ?: number ;
initialDelayMs ?: number ;
maxDelayMs ?: number ;
retryableErrors ?: number []; // リトライ対象のA2Aエラーコード
retryOnNetworkError ?: boolean ; // 接続断・タイムアウトを再試行するか
};
export async function withRetry < T >(
fn : () => Promise < T >,
options : RetryOptions = {}
) : Promise < T > {
const {
maxAttempts = 3 ,
initialDelayMs = 500 ,
maxDelayMs = 10_000 ,
retryableErrors = [ - 32603 ], // デフォルトはInternal errorのみ
retryOnNetworkError = true ,
} = options;
let lastError : Error | null = null ;
for ( let attempt = 1 ; attempt <= maxAttempts; attempt ++ ) {
try {
return await fn ();
} catch (error) {
lastError = error instanceof Error ? error : new Error ( String (error));
if ( ! isRetryable (lastError, retryableErrors, retryOnNetworkError)) {
throw lastError; // リトライ不可能なエラーは即座に再スロー
}
if (attempt < maxAttempts) {
// 指数バックオフ + ジッター
const delay = Math. min (
initialDelayMs * Math. pow ( 2 , attempt - 1 ) + Math. random () * 100 ,
maxDelayMs
);
console. warn ( `Attempt ${ attempt }/${ maxAttempts } failed. Retrying in ${ Math . round ( delay ) }ms...` );
await new Promise ( resolve => setTimeout (resolve, delay));
}
}
}
throw lastError ?? new Error ( "Max retry attempts exceeded" );
}
/** A2A のエラーコードが読み取れたときだけ判定に使う。読めない例外は素通ししない */
function isRetryable (
err : Error ,
retryableErrors : number [],
retryOnNetworkError : boolean
) : boolean {
const match = err.message. match ( /A2A Error (- ? \d + ):/ );
if (match) return retryableErrors. includes ( Number (match[ 1 ]));
// コードが読めない = A2A の応答ですらない(接続断・中断・こちら側のバグ)
return retryOnNetworkError && isTransportError (err);
}
function isTransportError ( err : Error ) : boolean {
const name = err.name;
return name === "TypeError" || name === "AbortError" || /fetch failed | ECONN | ETIMEDOUT/ i . test (err.message);
}
// 使用例 — タスクIDは呼び出しの外で1回だけ作る
const taskId = crypto. randomUUID ();
const result = await withRetry (
() => client. sendTask ({ taskId, message: "コードをレビューして" }),
{ maxAttempts: 3 , retryableErrors: [ - 32603 , - 32000 ] }
);
この2点は、自分で踏んでから直した箇所です。
ひとつ目は taskId の位置でした。最初に書いたときは crypto.randomUUID() をクロージャの内側に置いていたのですが、これだと再試行のたびに別のタスクIDが振られます 。相手のエージェントから見ると、同じ依頼が3件の無関係なタスクとして積み上がるわけです。冪等性のために ID を渡しているのに、その ID を毎回作り直していては意味がありません。後述する落とし穴でも触れますが、リトライで同じタスクIDを使うのは正しい振る舞いです。
ふたつ目は、コードを読み取れなかったときの既定値でした。以前は errorCode を -32603 にフォールバックさせていたので、A2A の応答ですらない例外——こちら側の TypeError や JSON の組み立てミスまで——が「サーバー内部エラー」として再試行の対象になっていました。リトライしてよいのは、相手が「一時的に無理です」と答えたときだけ。 自分の側の壊れ方を相手のせいにして3回投げ直すのは、ただ問題の発見を遅らせるだけでした。
Cloudflare Workers へのデプロイ
AntigravityとCloudflare Workersの相性は抜群です。A2Aエージェントをエッジにデプロイする手順を説明します。
// src/worker.ts
import { Hono } from "hono" ;
import { CODE_REVIEW_AGENT_CARD } from "./agent-card" ;
import { taskRouter } from "./routes/tasks" ;
const app = new Hono <{ Bindings : Env }>();
// Agent Card
app. get ( "/.well-known/agent-card.json" , ( c ) => {
const card = {
... CODE_REVIEW_AGENT_CARD ,
url: `https://${ new URL ( c . req . url ). hostname }` ,
};
return c. json (card, 200 , {
"Cache-Control" : "public, max-age=3600" ,
});
});
app. route ( "/tasks" , taskRouter);
export default app;
wrangler.toml の設定:
name = "code-review-agent"
main = "src/worker.ts"
compatibility_date = "2026-01-01"
compatibility_flags = [ "nodejs_compat" ]
[ vars ]
AGENT_BASE_URL = "https://code-review-agent.your-subdomain.workers.dev"
[[ secrets ]]
name = "GOOGLE_API_KEY"
[[ secrets ]]
name = "JWT_SECRET"
[[ secrets ]]
name = "AGENT_TOKEN"
デプロイコマンド:
# シークレットを設定
wrangler secret put GOOGLE_API_KEY
wrangler secret put JWT_SECRET
wrangler secret put AGENT_TOKEN
# デプロイ
wrangler deploy
# Agent Cardの確認
curl https://code-review-agent.your-subdomain.workers.dev/.well-known/agent-card.json | jq .
実践ユースケース — コードレビュー + ドキュメント生成パイプライン
複数のA2Aエージェントを連携させた実践的なユースケースを示します。
[オーケストレーター]
│
├──→ [コードレビューエージェント] → レビュー結果
│
├──→ [ドキュメント生成エージェント] → API仕様書
│
└──→ [テスト生成エージェント] → ユニットテスト
// src/orchestrator.ts
import { A2AClient } from "./a2a-client" ;
import { withRetry } from "./retry-client" ;
export async function runCodePipeline ( sourceCode : string ) {
const reviewClient = new A2AClient (
process.env. REVIEW_AGENT_URL ! ,
process.env. REVIEW_AGENT_TOKEN !
);
const docClient = new A2AClient (
process.env. DOC_AGENT_URL ! ,
process.env. DOC_AGENT_TOKEN !
);
// エージェントの能力を事前に確認
const [ reviewCard , docCard ] = await Promise . all ([
reviewClient. getAgentCard (),
docClient. getAgentCard (),
]);
console. log ( `✓ ${ reviewCard . name } (${ reviewCard . version }) - 接続OK` );
console. log ( `✓ ${ docCard . name } (${ docCard . version }) - 接続OK` );
// 並列でタスクを実行(互いに依存しないタスクは並列化)
const [ reviewResult , docResult ] = await Promise . all ([
withRetry (() => reviewClient. sendTask ({
taskId: crypto. randomUUID (),
message: `以下のコードをレビューしてください: \n\`\`\` typescript \n ${ sourceCode } \n\`\`\` ` ,
skillId: "review-code" ,
})),
withRetry (() => docClient. sendTask ({
taskId: crypto. randomUUID (),
message: `以下のコードのJSDocコメントとREADMEを生成してください: \n\`\`\` typescript \n ${ sourceCode } \n\`\`\` ` ,
})),
]);
return {
review: reviewResult.artifacts?.[ 0 ]?.parts?.[ 0 ]?.text ?? "" ,
documentation: docResult.artifacts?.[ 0 ]?.parts?.[ 0 ]?.text ?? "" ,
};
}
このパイプラインでは、コードレビューとドキュメント生成を並列実行 しているため、逐次実行と比べて処理時間を最大50%短縮できます。Antigravity IDEのエージェントモードで上記を開発する際は、「並列タスクはPromise.allで」というルールをrules.mdに追記しておくと、コード生成時に自動的に適用されます。
マルチエージェントシステムの基礎については AgentKit 2.0 プロダクション実装パターン も参考にしてください。また、MCPとの連携については カスタムMCPサーバー プロダクションガイド で詳しく解説しています。
規模が大きくなったときの設計 — タスクルーティングとエージェント探索
A2Aシステムが育ってくると、オーケストレーター側にエージェントのURLをハードコードする方式は保守の負担になってきます。ここでは動的なエージェント探索を解決する、軽量なエージェントレジストリのパターンを紹介します。
エージェントレジストリを実装する
// src/agent-registry.ts
type AgentRegistration = {
url : string ;
token : string ;
card ?: AgentCard ;
lastSeen : Date ;
healthy : boolean ;
};
export class AgentRegistry {
private agents = new Map < string , AgentRegistration >();
register ( name : string , url : string , token : string ) {
this .agents. set (name, { url, token, lastSeen: new Date (), healthy: true });
}
async resolve ( name : string ) : Promise <{ url : string ; token : string ; card : AgentCard }> {
const registration = this .agents. get (name);
if ( ! registration) throw new Error ( `未登録のエージェントです: ${ name }` );
if ( ! registration.healthy) throw new Error ( `エージェントが不健全な状態です: ${ name }` );
// 初回アクセス時にエージェントカードを取得してキャッシュする
if ( ! registration.card) {
const res = await fetch ( `${ registration . url }/.well-known/agent-card.json` );
registration.card = await res. json ();
}
return { url: registration.url, token: registration.token, card: registration.card ! };
}
/** 指定したスキルIDをサポートするエージェントを探す */
async findBySkill ( skillId : string ) : Promise < string []> {
const matches : string [] = [];
for ( const [ name , reg ] of this .agents. entries ()) {
if ( ! reg.card) {
try {
const res = await fetch ( `${ reg . url }/.well-known/agent-card.json` );
reg.card = await res. json ();
} catch {
reg.healthy = false ;
continue ;
}
}
const hasSkill = reg.card?.skills?. some ( s => s.id === skillId);
if (hasSkill) matches. push (name);
}
return matches;
}
/** 定期ヘルスチェック — 応答しないエージェントを不健全とマークする */
async healthCheck () {
const checks = Array. from ( this .agents. entries ()). map ( async ([ name , reg ]) => {
try {
const controller = new AbortController ();
const timeout = setTimeout (() => controller. abort (), 5000 );
const res = await fetch ( `${ reg . url }/.well-known/agent-card.json` , {
signal: controller.signal,
});
clearTimeout (timeout);
reg.healthy = res.ok;
reg.lastSeen = new Date ();
} catch {
reg.healthy = false ;
}
return { name, healthy: reg.healthy };
});
return Promise . all (checks);
}
}
// オーケストレーターでの利用例
const registry = new AgentRegistry ();
registry. register ( "code-review" , process.env. REVIEW_AGENT_URL ! , process.env. REVIEW_AGENT_TOKEN ! );
registry. register ( "documentation" , process.env. DOC_AGENT_URL ! , process.env. DOC_AGENT_TOKEN ! );
registry. register ( "test-generator" , process.env. TEST_AGENT_URL ! , process.env. TEST_AGENT_TOKEN ! );
// スキルを指定して動的にルーティングする
const reviewAgents = await registry. findBySkill ( "review-code" );
console. log ( `review-code スキルを持つエージェントが ${ reviewAgents . length } 件見つかりました` );
このレジストリの良いところは、エージェントを増やすときにオーケストレーターのコードを書き換えなくて済む点です。新しいエージェントを register するだけで、findBySkill 経由で自動的に探索対象に入ります。
スキル単位の負荷分散
同じスキルを複数のエージェントが提供している場合(たとえば冗長化のためにコードレビューエージェントを3インスタンス動かしているとき)、ラウンドロビンや最小負荷ルーティングでホットスポットを防げます。
// src/load-balancer.ts
export class RoundRobinBalancer {
private counters = new Map < string , number >();
next ( candidates : string []) : string {
if (candidates. length === 0 ) throw new Error ( "候補となるエージェントがありません" );
const key = candidates. join ( "|" );
const current = this .counters. get (key) ?? 0 ;
const selected = candidates[current % candidates. length ];
this .counters. set (key, current + 1 );
return selected;
}
}
// オーケストレーター内で、スキルに対して最も空いているエージェントを選ぶ
const balancer = new RoundRobinBalancer ();
async function delegateToSkill ( skillId : string , message : string ) {
const candidates = await registry. findBySkill (skillId);
if (candidates. length === 0 ) throw new Error ( `このスキルに対応するエージェントがありません: ${ skillId }` );
const agentName = balancer. next (candidates);
const { url , token } = await registry. resolve (agentName);
const client = new A2AClient (url, token);
return client. sendTask ({ taskId: crypto. randomUUID (), message, skillId });
}
パフォーマンスの実測値とチューニング
性能特性を把握しておくと、アーキテクチャ上のトレードオフを正しく判断できます。
レイテンシの内訳
典型的なA2A呼び出しのレイテンシは、大きく3つの層に分解できます。
ネットワークの往復 — HTTPリクエストとレスポンスの往復。ここは経路と距離で決まります
エージェントカードの取得 — エージェントごとに一度だけ。二度目以降をどう扱うかが設計の分かれ目です
タスク処理そのもの — モデルとプロンプトの複雑さで決まる部分
三つ目については、あえて具体的な秒数を書きません。使うモデルとプロンプト、その日の混み具合で桁が動く値なので、他人の環境で測った数字を目安として持ち帰っても、判断を誤らせるだけだと考えているからです。測ってよいのは、自分で再現できる層だけ。 この線引きは、記事に数字を書くときも自分のダッシュボードを見るときも守るようにしております。
代わりに、モデルを挟まないエージェントカードの取得層だけ を切り出して手元で測りました。Node 22 でカードを返すだけの HTTP サーバーを立て、同一ホスト内で 300 回ずつ計測した値です。ネットワーク距離を意図的にゼロに近づけ、サーバー処理とシリアライズのコストだけを見ています。
取得のしかた 平均 p50 p95 本文の転送
200 OK(毎回フル取得) 1.94 ms 1.92 ms 2.65 ms 910 バイト
304 Not Modified(If-None-Match による条件付き取得) 1.77 ms 1.78 ms 2.07 ms 0 バイト
プロセス内キャッシュのヒット 0.000 ms 0.000 ms 0.000 ms —
この表を作って、自分の思い込みがひとつ崩れました。条件付き取得は往復そのものは消してくれない のです。同一ホストでも 1.77 ミリ秒かかっていて、フル取得との差は 0.17 ミリ秒しかありません。実際の経路ではここに RTT が丸ごと乗りますから、レイテンシの観点では 200 も 304 もほとんど同じ買い物です。
つまり ETag が節約してくれるのはバイト数とオリジンの仕事 であって、待ち時間ではありません。待ち時間を消したいなら、往復自体を発生させないプロセス内キャッシュが要ります。条件付き取得が減らすのは転送量、プロセス内キャッシュが減らすのは往復の回数。 別々の効き方をするので、片方だけでは足りないのだと気づきました。
チューニングの指針
1. 独立したタスクは積極的に並列化しましょう。 オーケストレーターの例で示したとおり、Promise.all が最も効果の大きい最適化です。パイプラインの中に、互いに依存関係のない複数エージェントへのファンアウトがあるなら、それらは必ず並列で走らせます。
2. エージェントカードは、TTL と ETag を重ねてキャッシュしましょう。 よく見かけるのは TTL 付きの Map だけで済ませる実装ですが、それだと TTL が切れた瞬間に必ずフル取得へ落ちます。A2A の仕様は、Agent Discovery の Caching Considerations で、サーバーは Cache-Control と ETag を返すこと、クライアントは期限切れのときに If-None-Match による条件付きリクエストを使うことを推奨しています。上の実測が示すとおり、この二段構えにしておくと、カードが変わっていない限りボディの転送がゼロになり、変わっていれば同じ往復の中で新しいカードを受け取れます。
type CardEntry = { card : AgentCard ; etag : string | null ; expiresAt : number };
const cardCache = new Map < string , CardEntry >();
/** Cache-Control の max-age を尊重し、期限切れは If-None-Match で再検証する */
export async function getCachedAgentCard (
url : string ,
fallbackTtlMs = 3_600_000
) : Promise < AgentCard > {
const endpoint = `${ url . replace ( / \/ $ / , "" ) }/.well-known/agent-card.json` ;
const cached = cardCache. get (endpoint);
// 期限内ならネットワークに出ない(往復ゼロ)
if (cached && Date. now () < cached.expiresAt) return cached.card;
const headers : Record < string , string > = {};
if (cached?.etag) headers[ "If-None-Match" ] = cached.etag;
const res = await fetch (endpoint, { headers });
// 304 のときはボディが空。手元のカードをそのまま延命する
if (res.status === 304 && cached) {
cached.expiresAt = Date. now () + maxAgeMs (res, fallbackTtlMs);
return cached.card;
}
if ( ! res.ok) {
// 再検証に失敗しても、古いカードがあるうちは探索を止めない
if (cached) return cached.card;
throw new Error ( `Agent card fetch failed: ${ res . status }` );
}
const card = ( await res. json ()) as AgentCard ;
cardCache. set (endpoint, {
card,
etag: res.headers. get ( "etag" ),
expiresAt: Date. now () + maxAgeMs (res, fallbackTtlMs),
});
return card;
}
function maxAgeMs ( res : Response , fallbackMs : number ) : number {
const cc = res.headers. get ( "cache-control" ) ?? "" ;
const m = cc. match ( /max-age=( \d + )/ );
return m ? Number (m[ 1 ]) * 1000 : fallbackMs;
}
再検証に失敗したときに古いカードを返している箇所は、意図的にそうしています。カードの取得はタスクの実行ではなく探索 なので、ここで例外を投げると、実際には元気に動いているエージェントまで到達不能に見えてしまうのです。
3. 500ミリ秒を超えるタスクはストリーミングにしましょう。 半秒以上かかるタスクはストリーミングの恩恵を受けられます。UIの応答性が保たれ、ユーザーはスピナーを眺める代わりに進捗を確認できます。
4. リトライのパラメータはエラーの種類ごとに変えましょう。 すべてのエラーが同じリトライ挙動でよいわけではありません。レート制限は強めにバックオフ(5〜30秒)し、サーバー内部エラー(-32603)は短い間隔(500ミリ秒〜2秒)で再試行、接続断のようなネットワークエラーはユーザーに見せる前に2〜3回再試行する、というのが出発点になります。
5. エージェントカードは小さく絞りましょう——ただし理由はバイト数ではありません。 ここも実際に測って認識を改めた点です。同じ形のスキルを増やしてカードを膨らませ、JSON のバイト数と gzip 後のサイズ、JSON.parse の所要時間を比べました。
スキル数 JSON バイト数 gzip 後 JSON.parse
3 1,234 412 0.0070 ms
5 1,768 427 0.0088 ms
10 3,103 461 0.0151 ms
20 5,793 529 0.0278 ms
スキルを3個から20個へ増やしても、gzip 後は 412 バイトが 529 バイトになるだけでした。カードの構造は反復が多いので、圧縮がほとんど吸収してしまうのです。パースも 0.03 ミリ秒に届きません。カードを絞る理由は転送量ではなく、選ぶ側の迷いを減らすこと。 20個のスキルを並べた巨大なエージェントを1つ置くより、3〜5個に絞ったエージェントを複数用意するほうが、オーケストレーターのルーティングも、障害時の切り分けも楽になります。転送量を根拠に語ると、この本当の理由が見えなくなってしまいます。
v1.0 で名前が変わったところ — 既存の実装をどう畳むか
ここまでのコードは、JSON-RPC のメソッド名として tasks/send と tasks/sendSubscribe を使っています。動く形として分かりやすいので残していますが、v1.0 の変更点 を読むと、操作名は整理され直しています。既存のエージェントを持っている方は、先に対応表を見ておくと移行の見積もりが立てやすくなります。
v0.3.0 までの操作名 v1.0 の操作名 実装への影響
message/send(本記事の tasks/send 相当)SendMessageTask と Message のどちらを返すかの規定が明確化
message/stream(本記事の tasks/sendSubscribe 相当)SendStreamingMessageストリームイベントから kind と final が削除
tasks/getGetTask呼び出し元に見えるタスクだけを返すことが必須化
tasks/cancelCancelTaskキャンセル可能な状態遷移が明文化
tasks/resubscribeSubscribeToTask再接続時の挙動と購読ライフサイクルが規定
(なし) ListTasksフィルタとカーソルページングつきで新設
影響が大きいのは名前より構造の変更 のほうでした。ストリームイベントの kind 判別子が廃止され、TaskStatusUpdateEvent と TaskArtifactUpdateEvent は JSON のメンバー名で見分けます。列挙値もケバブケースから大文字スネークケースへ変わりました。エージェントカードでは protocolVersion がカード直下から個々の AgentInterface へ移り、preferredTransport と additionalInterfaces は supportedInterfaces[] に一本化されています。HTTP+JSON バインディングの URL からは /v1 接頭辞が外れました。
段取りとしては、受信側を先に両対応にして、送信側を後から切り替えると事故が減ります。まず受け取れるようにしてから、送り方の変更へ。 プロトコルの更新に限らず、送受信のあるものはこの順番で触るようにしております。逆にすると、切り替えた瞬間から相手が理解できないメッセージを投げ続けることになるためです。
つまずきやすいポイントと回避策
A2Aシステムを初めて作ると、いくつか繰り返し起きがちな失敗があります。先に知っておくと避けやすいものを挙げておきます。
落とし穴1: JWT の aud の設定漏れ。 JWTが対象オーディエンス(aud)を指定していないと、漏洩したトークンがシステム内のどのエージェントに対しても再利用できてしまいます。必ず aud: targetAgentId を含め、受信側で検証してください。
落とし穴2: 部分的なストリーミングアーティファクトの取りこぼし。 接続が切れたあとにストリーミングタスクへ再接続すると、クライアントがチャンクを重複して受け取ることがあります。artifact.index を追跡し、最後に正常処理したインデックスより小さいチャンクはスキップしましょう。
落とし穴3: 意味の違うリクエストへのタスクIDの使い回し。 タスクIDは論理的な作業単位ごとに一意であるべきです。リトライで同じタスクIDを使うのは冪等性の観点から正しい振る舞いですが(先ほどのリトライクライアントで ID を外に出したのはこのためです)、意味的に別のリクエストへ使い回すと、タスク状態をキャッシュしているエージェントで予測のつかない挙動を招きます。
落とし穴4: キャッシュヘッダーのないエージェントカード配信。 Cache-Control: public, max-age=3600 を付けないと、エージェント探索のたびに Worker の計算リソースを消費します。多数のオーケストレーターが利用可能なエージェントをポーリングする状況では、これがあっという間に積み上がります。あわせて ETag も返してください。仕様はカードの version フィールドか本文のハッシュから ETag を導くことを勧めていて、これがあるとクライアント側が条件付きリクエストで済ませられます。
落とし穴5: 過剰なヘルスチェック頻度。 20エージェントに対して5秒ごとにヘルスチェックを走らせるような積極的すぎる監視は、不要な負荷を生みます。多くの本番システムでは、30秒間隔・タイムアウト5秒あたりが妥当な出発点です。
運用してわかったこと — 標準プロトコルが効いてくる場面
個人開発でアプリを長く続けていると、バックエンドの非同期ジョブを複数のワーカーへ振り分ける仕組みは、どこかで必ず自前で作ることになります。私の場合もそうでした。そして一番こたえたのは、機能そのものよりも、自分で決めた独自プロトコルの保守でした。送信側と受信側のスキーマがいつのまにかずれてしまい、片方を直すともう片方が壊れるのです。誰も悪くないのに、直したはずの箇所が翌週また火を噴きます。
A2A のように、能力の宣言(エージェントカード)・タスクの受け渡し・エラーの返し方までが外側で決まっていることの値打ちは、こういう地味な保守の現場でこそ効いてきます。仕様が外にあるということは、議論の相手が同僚ではなくドキュメントになる ということでもあります。私はこの点をいちばん買っております。
運用してみて、ドキュメントだけでは気づきにくかった判断軸を三つ書き残します。
ひとつ目は、エージェントカードのキャッシュは「速度のため」だと思われがちですが、実際には障害の切り分けを楽にしてくれる という点です。先ほどの実測が示したとおり、キャッシュがレイテンシに効く量は構成によってはごくわずかです。それでも私が必ず入れるのは、カードを一度手元に置いておくと、タスクが失敗したときに「探索で落ちたのか、処理で落ちたのか」をログから即座に分離できるからでした。速度のための仕組みだと思っていたものが、切り分けのための仕組みだった——順序が逆だったのだと気づいたのは、深夜に原因を追いかけたあとでした。
ふたつ目は、ヘルスチェックの間隔は、エージェントの数ではなく「タスクの平均所要時間」を基準に決めるとうまくいく ことです。長時間タスクが多い構成で5秒間隔のチェックを回すと、健全なエージェントまで一時的にタイムアウトで不健全と判定されてしまいます。数が増えたから間隔を詰める、という直感は、たいてい逆方向に働きます。
みっつ目は、最初から完璧な探索機構を作り込まないこと です。エージェントが3〜4個のうちは、本記事のレジストリくらいの軽さで十分でした。要らない抽象化は、後から自分の首を絞めます。レジストリを賢くするのは、手作業のルーティングが面倒だと本当に感じてからで間に合うのです。
まとめと次の一歩
A2AプロトコルをAntigravityと組み合わせると、エージェントカードの設計から、タスク委譲、認証、レジストリによる動的探索、そしてCloudflare Workersへのデプロイまでを、ひとつの一貫した設計思想でつなげられます。本記事で繰り返し効いてきたのは、Promise.all による並列化と、エージェントカードのキャッシュという、派手さはないけれど効果の大きい2つの判断でした。そしてもうひとつ——仕様の側が動いている領域では、コードより先に一次情報を見にいくこと 。この記事を書き直すあいだ、私が一番助けられた習慣です。
次の一歩としては、まず本記事のコードレビューエージェントを1つだけ立て、/.well-known/agent-card.json を返すところまでを動かしてみてください。エージェントカードが正しく公開できれば、残りのタスク委譲やレジストリは、その土台の上に少しずつ積み上げていけます。最初から分散システム全体を設計しようとせず、1エージェントの往復が通る瞬間を作ることが、いちばんの近道だと感じています。
実装の参考になれば幸いです。お読みいただきありがとうございました。