ai-ml

دليل بروتوكول A2A: تنسيق الوكلاء المتعددين (2026)

١٦ يوليو ٢٠٢٦

A2A Protocol Tutorial: Multi-Agent Orchestration (2026)

بروتوكول Agent2Agent (A2A) هو معيار مفتوح من Linux Foundation لتمكين وكلاء الذكاء الاصطناعي من اكتشاف بعضهم البعض وتبادل المهام عبر HTTP و JSON-RPC. يبني هذا الدليل اثنين من وكلاء TypeScript الحقيقيين باستخدام @a2a-js/sdk الرسمي — وكيل فرعي (sub-agent) ومنسق (orchestrator) يقوم بالتفويض إليه — ويقوم بتشغيلهما من البداية إلى النهاية.

ملخص

ستقوم ببناء خادمين A2A مستقلين باستخدام @a2a-js/sdk@0.3.14 الرسمي: وكيل عملات (Currency Agent) يقوم بتحويل مبلغ إلى USD، و وكيل ميزانية رحلة (Trip Budget Agent) يقوم بتحليل المصروفات، واستدعاء وكيل العملات عبر A2A كعميل، وإرجاع بند الميزانية. هذا هو نمط المنسق/الوكيل الفرعي في الممارسة العملية — حيث يعمل المنفذ الخاص بوكيل ما كعميل A2A لوكيل نظير — وهو مبني فوق نموذج تفويض المهام في A2A بدلاً من أي شيء يفرضه البروتوكول مباشرة1.

ستتحقق من كلا الوكيلين باستخدام curl مقابل بطاقات الوكيل (Agent Cards) و JSON-RPC الخام، وتشغل سكربت عميل يستدعي المنسق بطلب حجب (blocking) وطلب تدفقي (streaming)، وترى مخرجات الطرفية (terminal) الحقيقية. وقت التشغيل: Node.js 22+، @a2a-js/sdk@0.3.14. وقت البناء: 30-40 دقيقة.

ما ستتعلمه

  • ما هي بطاقة الوكيل (Agent Card) وكيف يكتشفها العميل في /.well-known/agent-card.json
  • كيفية بناء خادم A2A باستخدام AgentExecutor و DefaultRequestHandler وتكامل Express
  • دورة حياة مهمة A2A (submittedworkingcompleted/failed) وكيفية إرفاق المصنوعات (artifacts) بمهمة ما
  • كيفية بناء منسق يقوم المنفذ الخاص به باستدعاء وكيل ثانٍ عبر A2A — الشكل العملي لنمط المنسق/الوكيل الفرعي
  • كيفية استهلاك كل من API sendMessage الحاجب و API sendMessageStream التدفقي (Server-Sent Events) من العميل الرسمي
  • أين ينتهي A2A ويبدأ MCP، حتى لا تستخدم البروتوكول الخاطئ

المتطلبات الأساسية

  • Node.js 22 أو أحدث. تم التحقق من هذا البرنامج التعليمي مقابل Node 22.22.3؛ Node 24 هو إصدار Active LTS الحالي (دعم نشط حتى أكتوبر 2026، دعم أمني حتى أبريل 2028)2. حزمة @a2a-js/sdk نفسها تتطلب فقط Node 18+، ولكن Node 18 و 20 قد انتهى عمرهما الافتراضي بحلول منتصف 20262، لذا لا تبنِ وكلاء جدد عليهما.
  • دراية أساسية بـ TypeScript و Express.
  • لا يلزم وجود مفتاح API — كلا الوكيلين في هذا البرنامج التعليمي يعملان بالكامل دون اتصال بالإنترنت بمنطق حتمي، لذا يمكنك المتابعة بدون مفتاح API من Anthropic أو OpenAI أو Google. في النشر الحقيقي، ستقوم باستبدال منطق عمل المنفذ باستدعاء لـ LLM (على سبيل المثال، حلقة استدعاء أدوات Messages API3)، ولكن توصيلات A2A نفسها متطابقة في كلتا الحالتين.

الخطوة 1: إعداد المشروع

أنشئ مشروعاً وقم بتثبيت SDK الرسمي بالإضافة إلى Express و uuid ومجموعة أدوات TypeScript:

mkdir a2a-tutorial && cd a2a-tutorial
npm init -y
npm install @a2a-js/sdk@0.3.14 express@5.2.1 uuid@14.0.1
npm install -D typescript@7.0.2 tsx@4.23.1 @types/node@26.1.1 @types/express@5.0.6

@a2a-js/sdk@0.3.14 هو أحدث إصدار على npm تحت علامة التوزيع latest وقت كتابة هذا الدليل، وهو يطبق مواصفات بروتوكول A2A الإصدار 0.345. هناك أيضاً إصدار 1.0.0-beta.0 على علامة next مع دعم لمواصفات v1.0 الأحدث — المزيد عن هذه المفاضلة أدناه.

أضف "type": "module" إلى package.json حتى تتمكن من استخدام await في المستوى الأعلى، ثم أنشئ tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "outDir": "dist",
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

قم بإنشاء دليل src/ — ستضيف إليه ثلاثة ملفات خلال الخطوات القليلة القادمة.

الخطوة 2: بناء الوكيل الفرعي للعملات (خادم A2A)

وكيل العملات هو الوكيل الفرعي في هذا النظام: خادم A2A صغير ومخصص لغرض واحد يقوم بتحويل مبلغ معين إلى USD. ليس لديه أي معرفة بالمنظم (orchestrator) الذي سيستدعيه — وهذا هو الهدف من تصميم A2A "المبهم"، حيث تتفاعل الوكلاء دون مشاركة المنطق الداخلي1.

يحتاج كل خادم A2A إلى ثلاثة أشياء: بطاقة وكيل (Agent Card) (مستند JSON يصف ما يفعله الوكيل)، و AgentExecutor (المنطق الفعلي للوكيل الخاص بك)، و معالج طلبات (request handler) متصل بوسيلة نقل (Express، في هذه الحالة). احفظ هذا الملف باسم src/currency-agent.ts:

// src/currency-agent.ts
// A2A sub-agent: converts an amount between currencies using a fixed demo rate table.
import express from "express";
import { v4 as uuidv4 } from "uuid";
import type { AgentCard, Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, Message } from "@a2a-js/sdk";
import {
  AgentExecutor,
  RequestContext,
  ExecutionEventBus,
  DefaultRequestHandler,
  InMemoryTaskStore,
} from "@a2a-js/sdk/server";
import { agentCardHandler, jsonRpcHandler, restHandler, UserBuilder } from "@a2a-js/sdk/server/express";

const PORT = 4001;

// Fixed demo rates (units of USD per 1 unit of the given currency). NOT live FX data —
// swap this for a real FX API (e.g. exchangerate.host) in production.
const RATES_TO_USD: Record<string, number> = {
  EUR: 1.08,
  GBP: 1.27,
  JPY: 0.0067,
  USD: 1,
};

const currencyAgentCard: AgentCard = {
  name: "Currency Agent",
  description: "Converts an amount from a supported currency into USD using a fixed reference rate table.",
  protocolVersion: "0.3.0",
  version: "0.1.0",
  url: `http://localhost:${PORT}/a2a`,
  skills: [
    {
      id: "convert-currency",
      name: "Convert Currency",
      description: "Convert an amount from EUR, GBP, or JPY into USD.",
      tags: ["finance", "currency"],
    },
  ],
  capabilities: {
    streaming: true,
    pushNotifications: false,
  },
  defaultInputModes: ["text"],
  defaultOutputModes: ["text"],
};

class CurrencyExecutor implements AgentExecutor {
  async execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void> {
    const { taskId, contextId, userMessage } = requestContext;

    const initialTask: Task = {
      kind: "task",
      id: taskId,
      contextId,
      status: { state: "submitted", timestamp: new Date().toISOString() },
      history: [userMessage],
    };
    eventBus.publish(initialTask);

    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "working", timestamp: new Date().toISOString() },
      final: false,
    } satisfies TaskStatusUpdateEvent);

    const textPart = userMessage.parts.find((p) => p.kind === "text");
    const text = textPart && textPart.kind === "text" ? textPart.text : "";
    const match = /convert\s+([\d.]+)\s+([A-Z]{3})\s+to\s+([A-Z]{3})/i.exec(text);

    const amountStr = match?.[1];
    const from = match?.[2];
    const to = match?.[3];

    if (!amountStr || !from || !to) {
      this.fail(eventBus, taskId, contextId, 'Could not parse request. Expected: "convert <amount> <FROM> to <TO>".');
      return;
    }

    const amount = Number(amountStr);
    const fromRate = RATES_TO_USD[from.toUpperCase()];
    const toRate = RATES_TO_USD[to.toUpperCase()];

    if (fromRate === undefined || toRate === undefined) {
      this.fail(eventBus, taskId, contextId, `Unsupported currency. Supported: ${Object.keys(RATES_TO_USD).join(", ")}.`);
      return;
    }

    const converted = Math.round(amount * fromRate * (1 / toRate) * 100) / 100;

    eventBus.publish({
      kind: "artifact-update",
      taskId,
      contextId,
      artifact: {
        artifactId: "conversion-result",
        name: "conversion.json",
        parts: [
          {
            kind: "data",
            data: { amount, from: from.toUpperCase(), to: to.toUpperCase(), converted },
          },
        ],
      },
    } satisfies TaskArtifactUpdateEvent);

    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "completed", timestamp: new Date().toISOString() },
      final: true,
    } satisfies TaskStatusUpdateEvent);
    eventBus.finished();
  }

  private fail(eventBus: ExecutionEventBus, taskId: string, contextId: string, reason: string): void {
    const failMessage: Message = {
      kind: "message",
      messageId: uuidv4(),
      role: "agent",
      contextId,
      parts: [{ kind: "text", text: reason }],
    };
    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "failed", timestamp: new Date().toISOString(), message: failMessage },
      final: true,
    } satisfies TaskStatusUpdateEvent);
    eventBus.finished();
  }

  async cancelTask(): Promise<void> {
    // Stateless, single-step task — nothing to clean up.
  }
}

const requestHandler = new DefaultRequestHandler(
  currencyAgentCard,
  new InMemoryTaskStore(),
  new CurrencyExecutor()
);

const app = express();
app.use("/.well-known/agent-card.json", agentCardHandler({ agentCardProvider: requestHandler }));
app.use("/a2a", jsonRpcHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
app.use("/a2a/rest", restHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));

app.listen(PORT, () => {
  console.log(`Currency Agent listening on http://localhost:${PORT}`);
});

هناك بعض الأشياء التي تستحق الذكر. الـ AgentCard مطلوبة للتصريح عن name، و description، و protocolVersion، و url، و version__PRESERpreserve__، و capabilities، وأوضاع الإدخال/الإخراج الافتراضية؛ أما الـ skills فتصف ما يمكن للوكيل القيام به فعلياً، وكل مهارة تحتاج إلى id، و name، و description، و tags4. يقوم المنفذ (executor) بنشر Task في حالة submitted، ثم ينقلها إلى working، ثم إما أن ينشر artifact-update يليه حالة completed، أو يفشل بحالة failed تحمل رسالة توضيحية — هذا الشكل المكون من أربع حالات (submittedworkingcompleted/failed) هو دورة حياة مهمة A2A بالكامل لعملية تبادل من دورة واحدة. تقوم eventBus.finished() بإغلاق دور المنفذ بغض النظر عن المسار الذي اتخذه.

الخطوة 3: التحقق من الوكيل الفرعي باستخدام curl وبطاقة الوكيل

قم بتشغيل الوكيل وتحقق من بطاقة الوكيل الخاصة به قبل كتابة سطر واحد من كود العميل:

npx tsx src/currency-agent.ts

في نافذة تيرمينال ثانية:

curl -s http://localhost:4001/.well-known/agent-card.json

هذا مخرج حقيقي تم التقاطه من ذلك الأمر تحديداً:

{"name":"Currency Agent","description":"Converts an amount from a supported currency into USD using a fixed reference rate table.","protocolVersion":"0.3.0","version":"0.1.0","url":"http://localhost:4001/a2a","skills":[{"id":"convert-currency","name":"Convert Currency","description":"Convert an amount from EUR, GBP, or JPY into USD.","tags":["finance","currency"]}],"capabilities":{"streaming":true,"pushNotifications":false},"defaultInputModes":["text"],"defaultOutputModes":["text"]}

الآن أرسل طلب JSON-RPC خام message/send — وهو نفس تنسيق السلك الذي يرسله عميل SDK في الخلفية، وهو مفيد عندما تريد رؤية ما يمر عبر الشبكة بالضبط قبل الوثوق في التجريد:

curl -s -X POST http://localhost:4001/a2a \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "message/send",
    "params": {
      "message": {
        "messageId": "demo-msg-1",
        "role": "user",
        "kind": "message",
        "parts": [{ "kind": "text", "text": "convert 150 EUR to USD" }]
      }
    }
  }'

استجابة حقيقية ملتقطة (تم إعادة تنسيقها لسهولة القراءة):

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "kind": "task",
    "id": "0b86b0f0-8a7f-4554-a830-689b89ee0647",
    "contextId": "2c42dad9-b982-4abc-bdc1-48ed87e487e2",
    "status": { "state": "completed", "timestamp": "2026-07-16T04:19:55.669Z" },
    "history": [
      {
        "messageId": "demo-msg-1",
        "role": "user",
        "kind": "message",
        "parts": [{ "kind": "text", "text": "convert 150 EUR to USD" }],
        "contextId": "2c42dad9-b982-4abc-bdc1-48ed87e487e2",
        "taskId": "0b86b0f0-8a7f-4554-a830-689b89ee0647"
      }
    ],
    "artifacts": [
      {
        "artifactId": "conversion-result",
        "name": "conversion.json",
        "parts": [
          { "kind": "data", "data": { "amount": 150, "from": "EUR", "to": "USD", "converted": 162 } }
        ]
      }
    ]
  }
}

150 * 1.08 = 162، لذا فإن حقل converted في الـ artifact يتطابق مع جدول الأسعار الثابت في الكود. حقل method يجب أن يكون السلسلة النصية الحرفية الدقيقة "message/send" — هذه ليست مجرد اتفاقية، بل هي جزء من نوع SendMessageRequest المُنشأ في SDK نفسه4.

الخطوة 4: بناء منظم ميزانية الرحلة (عميل + خادم A2A)

هنا يحدث الجزء "متعدد الوكلاء". وكيل ميزانية الرحلة هو في حد ذاته خادم A2A — لديه بطاقة وكيل خاصة به ومنفذ خاص به — ولكن داخل هذا المنفذ، يعمل كـ عميل A2A لوكيل العملات. قيام وكيل باستدعاء وكيل آخر عبر A2A، مع كون الوكيل المستدعي قابلاً للوصول بشكل مستقل كوكيل أيضاً، هو الشكل الملموس لنمط المنظم/الوكيل الفرعي في نظام A2A.

من الجدير بالذكر أن نكون دقيقين بشأن ما يحدث هنا بالفعل: A2A نفسها لا تحدد تسلسلات هرمية للوكلاء أو مفهوم الوكيل الفرعي — توضح وثائقها صراحةً أنها "ليست بروتوكولاً للوكلاء الفرعيين أو لاستدعاء الأدوات" ولا تحدد كيف يتحدث الوكيل مع وكلائه الفرعيين1.

ما تقوم ببنائه هو نمط على مستوى التطبيق — التفويض (delegation) — يتم تنفيذه باستخدام تبادل المهام من نظير إلى نظير (peer-to-peer) الخاص بـ A2A كوسيلة نقل. هذا هو نفس النمط الذي تستخدمه أمثلة Google المرجعية لخطوط الأنابيب عبر اللغات، مثل وكيل Python يقوم باستخراج شروط العقد ويسلم عملية التحقق إلى وكيل Go منفصل عبر A2A6.

احفظ هذا باسم src/trip-agent.ts:

// src/trip-agent.ts
// A2A orchestrator agent: parses an expense message, DELEGATES the currency conversion
// to the Currency Agent over A2A (acting as an A2A client from inside its own executor),
// then returns a budget summary. This is the orchestrator/sub-agent pattern built ON TOP
// of A2A's peer-to-peer task delegation -- A2A itself does not define agent hierarchies,
// it just gives two peer agents a common way to exchange a Task.
import express from "express";
import { v4 as uuidv4 } from "uuid";
import type { AgentCard, Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, Message } from "@a2a-js/sdk";
import {
  AgentExecutor,
  RequestContext,
  ExecutionEventBus,
  DefaultRequestHandler,
  InMemoryTaskStore,
} from "@a2a-js/sdk/server";
import { agentCardHandler, jsonRpcHandler, restHandler, UserBuilder } from "@a2a-js/sdk/server/express";
import { ClientFactory } from "@a2a-js/sdk/client";

const PORT = 4000;
const CURRENCY_AGENT_URL = "http://localhost:4001";

const tripAgentCard: AgentCard = {
  name: "Trip Budget Agent",
  description: "Logs a trip expense and converts it to USD by delegating to the Currency Agent over A2A.",
  protocolVersion: "0.3.0",
  version: "0.1.0",
  url: `http://localhost:${PORT}/a2a`,
  skills: [
    {
      id: "log-expense",
      name: "Log Expense",
      description: 'Log a foreign-currency expense, e.g. "I spent 150 EUR on hotel".',
      tags: ["finance", "travel", "orchestrator"],
    },
  ],
  capabilities: {
    streaming: true,
    pushNotifications: false,
  },
  defaultInputModes: ["text"],
  defaultOutputModes: ["text"],
};

class TripBudgetExecutor implements AgentExecutor {
  // One client, reused across requests, targeting the Currency Agent's base URL.
  private currencyClientFactory = new ClientFactory();

  async execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void> {
    const { taskId, contextId, userMessage } = requestContext;

    const initialTask: Task = {
      kind: "task",
      id: taskId,
      contextId,
      status: { state: "submitted", timestamp: new Date().toISOString() },
      history: [userMessage],
    };
    eventBus.publish(initialTask);

    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "working", timestamp: new Date().toISOString() },
      final: false,
    } satisfies TaskStatusUpdateEvent);

    const textPart = userMessage.parts.find((p) => p.kind === "text");
    const text = textPart && textPart.kind === "text" ? textPart.text : "";
    const match = /spent\s+([\d.]+)\s+([A-Z]{3})\s+on\s+(.+)/i.exec(text);

    const amountStr = match?.[1];
    const currency = match?.[2];
    const item = match?.[3];

    if (!amountStr || !currency || !item) {
      this.fail(eventBus, taskId, contextId, 'Could not parse request. Expected: "I spent <amount> <CUR> on <item>".');
      return;
    }

    // --- Delegate to the Currency Agent as an A2A client. ---
    let convertedAmount: number;
    try {
      const currencyClient = await this.currencyClientFactory.createFromUrl(CURRENCY_AGENT_URL);
      const delegateResult = await currencyClient.sendMessage({
        message: {
          messageId: uuidv4(),
          role: "user",
          parts: [{ kind: "text", text: `convert ${amountStr} ${currency.toUpperCase()} to USD` }],
          kind: "message",
        },
      });

      if (delegateResult.kind !== "task") {
        this.fail(eventBus, taskId, contextId, "Currency Agent did not return a task.");
        return;
      }
      const delegateTask = delegateResult as Task;
      if (delegateTask.status.state !== "completed" || !delegateTask.artifacts?.length) {
        this.fail(eventBus, taskId, contextId, `Currency Agent could not convert ${amountStr} ${currency}.`);
        return;
      }
      const dataPart = delegateTask.artifacts[0]?.parts.find((p) => p.kind === "data");
      if (!dataPart || dataPart.kind !== "data") {
        this.fail(eventBus, taskId, contextId, "Currency Agent artifact had no data part.");
        return;
      }
      convertedAmount = (dataPart.data as { converted: number }).converted;
    } catch (err) {
      this.fail(eventBus, taskId, contextId, `Could not reach Currency Agent: ${(err as Error).message}`);
      return;
    }

    const summary = `${item.trim()}: ${amountStr} ${currency.toUpperCase()} -> $${convertedAmount.toFixed(2)} USD`;

    eventBus.publish({
      kind: "artifact-update",
      taskId,
      contextId,
      artifact: {
        artifactId: "budget-line",
        name: "budget-line.txt",
        parts: [{ kind: "text", text: summary }],
      },
    } satisfies TaskArtifactUpdateEvent);

    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "completed", timestamp: new Date().toISOString() },
      final: true,
    } satisfies TaskStatusUpdateEvent);
    eventBus.finished();
  }

  private fail(eventBus: ExecutionEventBus, taskId: string, contextId: string, reason: string): void {
    const failMessage: Message = {
      kind: "message",
      messageId: uuidv4(),
      role: "agent",
      contextId,
      parts: [{ kind: "text", text: reason }],
    };
    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "failed", timestamp: new Date().toISOString(), message: failMessage },
      final: true,
    } satisfies TaskStatusUpdateEvent);
    eventBus.finished();
  }

  async cancelTask(): Promise<void> {
    // Single-hop delegation completes quickly enough that we don't track cancellation state.
  }
}

const requestHandler = new DefaultRequestHandler(
  tripAgentCard,
  new InMemoryTaskStore(),
  new TripBudgetExecutor()
);

const app = express();
app.use("/.well-known/agent-card.json", agentCardHandler({ agentCardProvider: requestHandler }));
app.use("/a2a", jsonRpcHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
app.use("/a2a/rest", restHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));

app.listen(PORT, () => {
  console.log(`Trip Budget Agent listening on http://localhost:${PORT}`);
});

لاحظ أن TripBudgetExecutor يستخدم نفس الـ API الذي ستستخدمه في سكريبت عميل مستقل في الخطوة التالية. من وجهة نظر الـ SDK، لا يوجد فرق بين "تطبيق موجه للبشر يتحدث إلى وكيل" و"وكيل يتحدث إلى وكيل آخر" — كلاهما مجرد عملاء A2A يستدعون مقابل URL أساسي. هذا التماثل هو ما يجعل نمط المنسق (orchestrator) سهل البناء: المنفذ الخاص بالمنسق لديك هو في نفس الوقت خادم A2A (لكل من يستدعيه) وعميل A2A (لكل من يستدعيه هو).

الخطوة 5: تشغيل نظام الوكلاء المتعددين الكامل

قم بتشغيل كلا الوكيلين، ثم قم بتشغيل سكريبت عميل يتحدث فقط مع وكيل ميزانية الرحلة (Trip Budget Agent) — فهو لا يتحدث أبداً مع وكيل العملات (Currency Agent) مباشرة، لأنه لا يحتاج إلى ذلك.

احفظ هذا باسم src/client-demo.ts:

// src/client-demo.ts
// Stands in for a human-facing app: discovers the Trip Budget Agent's Agent Card,
// sends it an expense, and prints the delegated result. Then repeats the call using
// the streaming API to show live task-lifecycle events.
import { ClientFactory } from "@a2a-js/sdk/client";
import type { Message, MessageSendParams, Task } from "@a2a-js/sdk";
import { v4 as uuidv4 } from "uuid";

const TRIP_AGENT_URL = "http://localhost:4000";

async function sendOnce() {
  const factory = new ClientFactory();
  const client = await factory.createFromUrl(TRIP_AGENT_URL);

  const params: MessageSendParams = {
    message: {
      messageId: uuidv4(),
      role: "user",
      parts: [{ kind: "text", text: "I spent 150 EUR on hotel" }],
      kind: "message",
    },
  };

  const result = await client.sendMessage(params);

  if (result.kind === "task") {
    const task = result as Task;
    console.log(`[sendMessage] Task ${task.id} finished with status: ${task.status.state}`);
    const textPart = task.artifacts?.[0]?.parts.find((p) => p.kind === "text");
    if (textPart && textPart.kind === "text") {
      console.log(`[sendMessage] Artifact: ${textPart.text}`);
    }
  } else {
    const message = result as Message;
    const textPart = message.parts.find((p) => p.kind === "text");
    console.log("[sendMessage] Direct message:", textPart && textPart.kind === "text" ? textPart.text : message);
  }
}

async function streamOnce() {
  const factory = new ClientFactory();
  const client = await factory.createFromUrl(TRIP_AGENT_URL);

  const params: MessageSendParams = {
    message: {
      messageId: uuidv4(),
      role: "user",
      parts: [{ kind: "text", text: "I spent 42 GBP on taxi" }],
      kind: "message",
    },
  };

  const stream = client.sendMessageStream(params);
  for await (const event of stream) {
    if (event.kind === "task") {
      console.log(`[stream] Task ${event.id} created. Status: ${event.status.state}`);
    } else if (event.kind === "status-update") {
      console.log(`[stream] Task ${event.taskId} status -> ${event.status.state}`);
    } else if (event.kind === "artifact-update") {
      const textPart = event.artifact.parts.find((p) => p.kind === "text");
      console.log(`[stream] Artifact received: ${event.artifact.name}${textPart && textPart.kind === "text" ? ` ("${textPart.text}")` : ""}`);
    }
  }
  console.log("[stream] --- stream finished ---");
}

async function main() {
  console.log("=== Blocking call (sendMessage) ===");
  await sendOnce();
  console.log("\n=== Streaming call (sendMessageStream) ===");
  await streamOnce();
}

await main();

ابدأ كلا الخادمين في طرفيات (terminals) منفصلة، ثم قم بتشغيل العميل:

# terminal 1
npx tsx src/currency-agent.ts
# terminal 2
npx tsx src/trip-agent.ts
# terminal 3
npx tsx src/client-demo.ts

هذه هي المخرجات الحقيقية وغير المعدلة من تشغيل هذا التسلسل بالضبط:

=== Blocking call (sendMessage) ===
[sendMessage] Task c10d0b3d-b875-4d81-8a6a-fdedc326fc3e finished with status: completed
[sendMessage] Artifact: hotel: 150 EUR -> $162.00 USD

=== Streaming call (sendMessageStream) ===
[stream] Task 7719ebd8-18a9-43fa-8dd1-05489c6c69b2 created. Status: submitted
[stream] Task 7719ebd8-18a9-43fa-8dd1-05489c6c69b2 status -> working
[stream] Artifact received: budget-line.txt ("taxi: 42 GBP -> $53.34 USD")
[stream] Task 7719ebd8-18a9-43fa-8dd1-05489c6c69b2 status -> completed
[stream] --- stream finished ---

كلا الرقمين صحيحان: ، و . معرفات المهام (Task IDs) هي UUIDs عشوائية يتم إنشاؤها من جديد في كل مرة يتم فيها التشغيل، لذا ستختلف معرفاتك — أما كل شيء آخر فيجب أن يتطابق تماماً.

الخطوة 6: ماذا يظهر لك استدعاء البث (Streaming Call) في الواقع

استدعاء المعيق (blocking) في لا يعود إلا بعد أن ينتهي منفذ وكيل ميزانية الرحلة — حيث تحصل على كائن النهائي مع حالة والنتيجة (artifact)، ولا شيء بينهما. أما استدعاء البث في فيستخدم ، والذي يعيد عبر أحداث مرسلة من الخادم (Server-Sent Events)، لذا ترى كل حدث ينشره المنفذ في الخاص به لحظة حدوثه: حدث إنشاء الأولي، وكل (هنا فقط ثم ، لأن سلسلة عمل هذا المنفذ تنتهي بسرعة)، و الذي يحمل سطر الميزانية47.

هذا الأمر يصبح مهماً عندما يستغرق عمل الوكيل وقتاً أطول مما تتحمله دورة "طلب-استجابة" مريحة — مثل مهمة بحث متعددة الخطوات، أو خط أنابيب بيانات طويل الأمد، أو أي شيء يرغب فيه المستدعي (سواء كان بشراً أو وكيلاً) في رؤية التقدم بدلاً من مؤشر تحميل فارغ. مواصفات A2A تمنح العملاء خياراً صريحاً هنا: يمكنهم استخدام الاستطلاع (poll)، أو البث (stream)، أو تسجيل webhook للإشعارات الفورية، اعتماداً على ما تتطلبه طبيعة العمل وبنية المستدعي نفسه8.

الوكلاء في هذا البرنامج التعليمي سريعون بما يكفي لدرجة أن الفرق بين البث والتعليق لا يكاد يذكر وظيفياً، ولكن التوصيلات هي نفسها تماماً لمنفذ تستغرق طريقة الخاصة به ثلاثين ثانية بدلاً من ثلاثين مللي ثانية — ستقوم فقط بنشر المزيد من أحداث على طول الطريق.

A2A مقابل MCP: متى تستخدم كل منهما

إذا كنت قد استخدمت Model Context Protocol (MCP)، فمن المهم أن نكون دقيقين في كيفية ارتباطه بـ A2A، لأن الاثنين يتم الخلط بينهما باستمرار. توضح وثائق البروتوكول نفسها الخط الفاصل بوضوح: يقوم MCP بتوحيد كيفية اتصال وكيل واحد بأدواته وواجهات برمجة التطبيقات (APIs) ومصادر البيانات الخاصة به؛ بينما يقوم A2A بتوحيد كيفية اكتشاف الوكلاء المستقلين لبعضهم البعض والتواصل عبر الحدود التنظيمية أو حدود المنصات1.

قاعدة عملية مبسطة: إذا كنت تمنح وكيلاً واحداً إمكانية الوصول إلى قاعدة بيانات، أو فهرس بحث، أو مستودع GitHub، فهذه مشكلة MCP — راجع شرحنا لبناء MCP client باستخدام stdio transport9. أما إذا كنت تسمح لوكيلين تم نشرهما بشكل مستقل — ربما تم بناؤهما على أطر عمل مختلفة تماماً، وبواسطة فرق مختلفة — بتبادل مهمة ونتيجة، فهذه مشكلة A2A. في الواقع، غالباً ما يتم استخدام الاثنين معاً: MCP داخل الوكيل، و A2A بين الوكلاء8.

التحقق

ما تم تشغيله فعلياً لهذا البرنامج التعليمي، وما لم يتم تشغيله:

  • تم تنفيذه فعلياً: تم تجميع الملفات الثلاثة المذكورة أعلاه بنجاح باستخدام tsc --strict --noUncheckedIndexedAccess --noEmit مقابل الإصدار المثبت فعلياً من @a2a-js/sdk@0.3.14. تم تشغيل كلا الخادمين باستخدام tsx، وتم تشغيل client-demo.ts ضدهما — مخرجات الطرفية (terminal) في الخطوة 5 منسوخة حرفياً من ذلك التشغيل، وليست مُعاد تركيبها. استدعاءات curl الخام في الخطوة 3 تم تشغيلها مقابل عملية Currency Agent الحية.
  • لم يتم تنفيذه: لا توجد استدعاءات LLM حية. يستخدم كلا المنفذين (executors) تحليلاً حتمياً وجدول بحث ثابتاً خصيصاً لكي يعمل البرنامج التعليمي بدون أي تبعات خارجية وبدون أي مفاتيح API. إذا قمت بربط نموذج حقيقي بطريقة execute() في أي من المنفذين، فإن البنية التحتية لخادم/عميل A2A المحيطة بها لن تتغير.

للتأكد من أن إعداداتك تعمل، قم بتشغيل أمري curl من الخطوة 3 وتحقق من أن بطاقة Currency Agent تسرد "protocolVersion":"0.3.0" وأن تحويل 150 EUR يعيد "converted":162.

استكشاف الأخطاء وإصلاحها

Error: listen EADDRINUSE: address already in use :::4000 (أو :4001) — إحدى عمليتي الوكيل تعمل بالفعل من محاولة سابقة. ابحث عنها وأوقفها (lsof -i :4000 على macOS/Linux، ثم kill <pid>)، أو قم بتغيير PORT في الملف المقابل.

العميل يتوقف عن الاستجابة أو يظهر خطأ في الاتصال عند استخدام createFromUrl — تقوم ClientFactory.createFromUrl بجلب /.well-known/agent-card.json من عنوان URL الأساسي الذي تمرره قبل أن تتمكن من إرسال أي شيء. إذا كان الوكيل المستهدف لا يعمل بعد، فقم بتشغيله أولاً؛ إذا قمت بتغيير المنفذ، فتأكد من تحديث CURRENCY_AGENT_URL في trip-agent.ts و TRIP_AGENT_URL في client-demo.ts ليتطابقا.

Currency Agent did not return a task. — هذا هو مسار الخطأ الخاص بالمنسق (orchestrator)، مما يعني أن Currency Agent استجاب ولكن بـ Message مباشرة بدلاً من Task. في هذا البرنامج التعليمي، يحدث هذا فقط إذا تم تعديل CurrencyExecutor لتخطي نشر كائن Task الأولي — الحل هو التأكد من أن eventBus.publish(initialTask) لا يزال يعمل قبل أي حدث آخر.

Cannot find module '@a2a-js/sdk/server/express' — تكامل Express هو تصدير مسار فرعي منفصل، وExpress نفسه هو تبعية peer وليس تبعية انتقالية7. إذا رأيت هذا، قم بتشغيل npm install express بشكل صريح حتى لو كان @a2a-js/sdk مثبتًا بالفعل.

TypeScript يشتكي من أن مجموعة التقاط regex قد تكون undefined — يحدث هذا تحت إعدادات --strict --noUncheckedIndexedAccess (المستخدمة في هذا البرنامج التعليمي) لأن TypeScript لا يمكنه إثبات أن مطابقة regex الناجحة قد ملأت كل مجموعة التقاط، حتى عندما لا يحتوي النمط على مجموعات اختيارية. يتعامل كلا المنفذين مع هذا عن طريق تفكيك البنية باستخدام match?.[1] والحماية باستخدام فحص صريح if (!amountStr || ...) قبل استخدام القيم — لا تلجأ إلى تأكيد غير فارغ (!) بدلاً من ذلك، لأنه يلغي بصمت نفس فحص السلامة الذي وجد noUncheckedIndexedAccess لتوفيره.

الاختيار بين SDK المستقر ونسخة v1.0 Beta

شيء آخر يستحق اتخاذ قرار مدروس بشأنه بدلاً من تركه للصدفة: وصل A2A كـ بروتوكول إلى أول مواصفات مستقرة، v1.0، في 12 مارس 2026، والتي شملت بطاقات Agent الموقعة، ودعم تعدد المستأجرين (multi-tenancy)، ومسار هجرة محدد من v0.3810. وأكد التقرير الاستعادي لاعتماد مؤسسة Linux لمدة عام، والذي نُشر بعد حوالي أربعة أسابيع، على تبني قوي للمواصفات الجديدة من قبل الشركات11.

لكن علامة التوزيع latest الخاصة بـ @a2a-js/sdk على npm — وهي التي يقوم هذا البرنامج التعليمي بتثبيتها — لا تزال 0.3.14، والتي تطبق مواصفات v0.3؛ بينما يتوفر دعم v1.0 حالياً على علامة next الخاصة بالحزمة كإصدار 1.0.0-beta.0، والذي نُشر قبل حوالي أسبوعين من كتابة هذا البرنامج التعليمي4. وكان إعلان البروتوكول نفسه صريحاً بشأن هذه الفجوة، مشيراً إلى أن المجتمع كان لا يزال "يركز على تقديم دعم SDK v1.0 لعدة لغات" حتى مع صدور المواصفات نفسها8.

بالنسبة لبرنامج تعليمي — أو مشروع تبدأه اليوم — فإن التثبيت على الإصدار المستقر 0.3.14 هو الخيار الافتراضي الأكثر أماناً؛ تتبع علامة next إذا كنت بحاجة تحديداً إلى ميزات تعدد المستأجرين أو بطاقات Agent الموقعة في v1.0 ويمكنك تحمل التغييرات الجذرية بينما ينضج دعم v1.0 في JS SDK.

الخطوات التالية

من هنا، الامتداد الطبيعي هو منح أحد هؤلاء الوكلاء ذكاءً حقيقياً بدلاً من محلل regex — قم بربط CurrencyExecutor.execute() أو TripBudgetExecutor.execute() بحلقة استدعاء أدوات LLM، باستخدام نفس نمط الحلقة الوكيلية من برنامجنا التعليمي لـ Messages API الخام3، ولن تحتاج طبقة A2A المحيطة بها إلى أي تغيير على الإطلاق.

إذا كان وكلاؤك بحاجة إلى استدعاء أدوات خارجية (قاعدة بيانات، API بحث، نظام ملفات) بدلاً من استدعاء بعضهم البعض، فهذه مهمة MCP وليس A2A — راجع البرنامج التعليمي لعميل MCP للاطلاع على شرح نقل stdio9. وإذا كان هناك منسق مثل هذا سيقوم باتخاذ إجراءات واقعية نيابة عن المستخدم — مثل إنفاق الأموال، إرسال الرسائل، أو تعديل البيانات — فقم بدمجه مع بوابات موافقة بشرية (human-in-the-loop) قبل إطلاقه12.

الحواشي السفلية

  1. الصفحة الرئيسية لبروتوكول A2A — "كيف يعمل A2A مع MCP" و"ما الذي لا يمثله A2A" — a2a-protocol.org، تم الدخول في 2026-07-16. 2 3 4

  2. Node.js — endoflife.date — تم الدخول في 2026-07-16، آخر تحديث للصفحة 2026-07-14. 2

  3. استخدام أدوات Claude في TypeScript: دليل حلقة الوكيل (2026) 2

  4. @a2a-js/sdk على npm — الإصدار 0.3.14، نُشر في 2026-07-09. 2 3 4 5

  5. مواصفات بروتوكول A2A الإصدار 0.3 — a2a-protocol.org.

  6. بناء فريق متعدد الوكلاء وعابر للغات باستخدام مجموعة تطوير الوكلاء من Google و A2A — مدونة مطوري Google.

  7. a2aproject/a2a-js — SDK الرسمي لـ JavaScript لبروتوكول Agent2Agent (A2A) — GitHub، تم الدخول في 2026-07-16. 2

  • الإعلان عن الإصدار 1.0 — A2A Protocol — a2a-protocol.org، تم الوصول إليه في 2026-07-16. 2 3 4

  • بناء MCP Client باستخدام TypeScript: دليل تعليمي لعام 2026 2

  • إصدار A2A v1.0.0 — GitHub، صدر في 2026-03-12. إعلان Linux Foundation في 9 أبريل 202611 هو مراجعة لاعتماد البروتوكول بعد مرور عام، نُشرت بعد حوالي أربعة أسابيع من شحن المواصفات نفسها، وليس تاريخ الإصدار.

  • بروتوكول A2A يتجاوز 150 منظمة، ويصل إلى منصات سحابية كبرى، ويشهد استخداماً إنتاجياً في الشركات في عامه الأول — The Linux Foundation، 9 أبريل 2026. أكثر من 150 منظمة داعمة، وأكثر من 22,000 نجمة على GitHub في مستودع المواصفات الأساسية، وSDKs بخمس لغات جاهزة للإنتاج. 2

  • إضافة موافقة "الإنسان في الحلقة" (Human-in-the-Loop) إلى Claude Agent SDK (2026)