diff --git a/.agents/skills/a2a-protocol/SKILL.md b/.agents/skills/a2a-protocol/SKILL.md index 9a4635025ab..691ae00fa27 100644 --- a/.agents/skills/a2a-protocol/SKILL.md +++ b/.agents/skills/a2a-protocol/SKILL.md @@ -28,13 +28,16 @@ fixing it. If A2A delegation is unreliable, fix A2A — file it as a bug in the delegation path (timeout handling, retries, typed terminal states), don't route around it app by app. -Connecting app A to app B is two independent things, and both must be true: +Connecting app A to an Agent-Native app B is two independent things, and both +must be true: 1. **B is registered on A** as a `remote-agents/.json` resource. 2. **A and B share a secret**, so A's signed JWT verifies on B. Neither is a code change, and neither is symmetric. Registering B on A does not -let B call A. +let B call A. A hosted peer uses provider-specific endpoint and credential +metadata in place of the shared Agent-Native secret, but it still does not make +the provider's model or SDK an A2A endpoint. ## A2A is already mounted @@ -62,13 +65,30 @@ A remote agent is **a row in the resources table, not a file on disk**. Path "name": "Analytics", "description": "Queries analytics data across providers", "url": "https://analytics.example.com", - "color": "#6B7280" + "color": "#6B7280", + "cardUrl": "https://analytics.example.com/.well-known/agent-card.json", + "auth": { + "type": "bearer", + "credentialRef": "ANALYTICS_A2A_TOKEN" + } } ``` -`url` is the only required field. `parseRemoteAgentManifest` accepts **only** -these five keys — there is no `apiKey`, `env`, `skills`, or `token` field, and -anything else is silently dropped. +`url` is the only required endpoint field. `cardUrl` is the optional discovery +URL for providers that do not serve `/.well-known/agent-card.json`. The client +reads the protocol version from the agent card. Pass `protocolVersion` directly +to `A2AClient` when a provider's card omits it. The optional `auth` descriptor is +non-secret connection metadata. Use +`{ "type": "bearer", "credentialRef": "..." }` for a vault-backed bearer +credential, or `{ "type": "oauth-client-credentials", "tokenUrl": "...", +"clientId": "...", "clientSecretRef": "...", "scope": "..." }` when the +peer issues OAuth client-credentials tokens. `credentialRef` and +`clientSecretRef` are references, never secret values. + +Do not add `apiKey`, `env`, `skills`, or `token` values to a manifest. Resolve +credentials server-side from the workspace connection or vault. A direct +`A2AClient` call can pass `cardUrl` and `protocolVersion` while a provider +adapter is being used, but browser code must never receive the credential. Four ways to create it, all writing the same row: @@ -139,7 +159,34 @@ request is genuine loopback or `A2A_ALLOW_UNSIGNED_INTERNAL=1`. `A2AConfig.apiKeyEnv` still exists for static bearer auth against non-agent-native peers, but the framework's own mount never sets it. Do not reach for it when debugging a connection between two agent-native apps — the answer there is -always the shared secret. +always the shared secret. For a provider that uses OAuth, resolve and refresh +the access token in a server-side adapter. `A2A_SECRET` is not a substitute for +the provider's Entra, Google, or other OAuth credential. + +## Hosted providers + +Foundry, Gemini Enterprise, and other hosted services can be A2A peers only +when they expose a compatible protocol endpoint. Foundry hosted agents run +agent code in managed containers and can expose A2A through Agent Service. Keep +the Agent-Native UI, actions, and PostgreSQL app on its normal host. Foundry +callers use Microsoft Entra bearer tokens with Foundry Agent Consumer access, +so configure `cardUrl` and obtain the token through the workspace credential +provider. Foundry v1.0 is the GA JSON-RPC endpoint; v0.3 is the preview +endpoint used when no version is selected. Use the v1 card URL +`.../agents/{agent}/endpoint/protocols/a2a/agentCard/v1.0` when available, +and pass `protocolVersion` only when the card omits it. Foundry v1 does not +provide SSE streaming. See the [Foundry hosted agent overview](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) +and [A2A endpoint guidance](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint). + +Gemini Enterprise managed assistants expose a standard A2A JSON-RPC endpoint +under the assistant resource, such as +`.../assistants/default_assistant/agents/{id}/a2a`, and can publish the card at +a custom URL. Use the generic bearer client with that `cardUrl`; the caller's +Google OAuth bearer needs the `discoveryengine.assist` permission. Custom A2A +agent registration is Pre-GA, so verify that it is enabled in the target +project before exposing it in the workspace picker. A model provider or SDK +without an A2A endpoint still needs a server-side adapter before an +Agent-Native app can call it. Never hardcode either secret in source, docs, prompts, app state, action descriptions, client bundles, or examples. Read them from runtime config; never diff --git a/.changeset/hosted-a2a-peer-connections.md b/.changeset/hosted-a2a-peer-connections.md new file mode 100644 index 00000000000..7e282b37de8 --- /dev/null +++ b/.changeset/hosted-a2a-peer-connections.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Document hosted A2A peer connection metadata and provider-specific authentication guidance for Foundry and Gemini Enterprise. diff --git a/packages/core/docs/content/a2a-protocol.mdx b/packages/core/docs/content/a2a-protocol.mdx index e431052d768..ec9b9426def 100644 --- a/packages/core/docs/content/a2a-protocol.mdx +++ b/packages/core/docs/content/a2a-protocol.mdx @@ -364,6 +364,112 @@ const response = await callAgent( console.log(response); // "There were 1,247 signups last week..." ``` +## Hosted-agent connections {#hosted-agent-connections} + +A hosted agent can participate in A2A when it exposes an A2A endpoint. A model +provider or agent SDK alone is not an A2A peer. Keep the app's UI, actions, and +database on your normal app host, then connect the agent runtime through an +adapter or its A2A endpoint. + +A hosted connection has endpoint metadata and an auth descriptor. Keep this +metadata with the remote-agent resource or connection record. Never put a token +or client secret in the manifest: + +```json +{ + "id": "foundry-research", + "name": "Foundry Research", + "url": "https://example.services.ai.azure.com/agents/foundry-research/endpoint/protocols/a2a", + "cardUrl": "https://example.services.ai.azure.com/agents/foundry-research/endpoint/protocols/a2a/agentCard/v1.0", + "auth": { + "type": "bearer", + "credentialRef": "FOUNDRY_A2A_TOKEN" + } +} +``` + +`url` is the invocation endpoint or base URL. `cardUrl` is optional when +discovery uses `/.well-known/agent-card.json`. Use it when the provider +publishes its card elsewhere. The client reads the protocol version from the +agent card. When a provider's card omits it, pass `protocolVersion` directly to +`A2AClient`. `auth` names how server-side code obtains a bearer token. Supported +descriptors are `bearer` with a vault-backed +`credentialRef`, or `oauth-client-credentials` with `tokenUrl`, `clientId`, +`clientSecretRef`, and `scope`. `credentialRef` and `clientSecretRef` refer to +secrets, not secret values. + +### Generic bearer A2A {#generic-bearer-a2a} + +Use the standard client when the peer accepts `Authorization: Bearer ...`, +serves a compatible card, and accepts Agent-Native JSON-RPC: + +```ts +const client = new A2AClient(endpoint, token, { + cardUrl, + protocolVersion: "0.3", +}); +``` + +Resolve `token` server-side from the workspace connection or vault. Do not +send it from browser code, write it to a manifest, or include it in a prompt. +A static API key is only a transport credential for the remote service. It does +not establish Agent-Native caller identity or carry `approvedActions`. + +### Microsoft Foundry {#microsoft-foundry} + +[Azure AI Foundry hosted agents](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) +run your agent code or container behind a managed endpoint, identity, scaling, +session state, and observability. This hosting model is for the agent runtime. +Keep your Agent-Native UI, actions, and PostgreSQL deployment on the app host +and call the Foundry endpoint from server-side code. + +Foundry can expose an A2A endpoint through [Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint). +It requires Microsoft Entra authentication, and the caller needs Foundry Agent +Consumer permission. Version 1.0 is the GA JSON-RPC endpoint; use its +`.../agents/{agent}/endpoint/protocols/a2a/agentCard/v1.0` card URL when available. Version 0.3 is the preview +endpoint returned when no version is selected. Pass `protocolVersion` to +`A2AClient` only when the card omits it. Foundry v1 does not provide SSE +streaming, so the client uses `message/send`. The token provider must obtain +and refresh an Entra access token. `A2A_SECRET` and a static API key do not +satisfy Foundry authentication. + +For service-principal client credentials, request the Entra scope +`https://ai.azure.com/.default` and grant the caller the **Foundry Agent +Consumer** role at the project or agent scope. Store the client secret in the +vault and reference it from the manifest: + +```json +{ + "auth": { + "type": "oauth-client-credentials", + "tokenUrl": "https://login.microsoftonline.com//oauth2/v2.0/token", + "clientId": "", + "clientSecretRef": "FOUNDRY_CLIENT_SECRET", + "scope": "https://ai.azure.com/.default" + } +} +``` + +### Gemini Enterprise {#gemini-enterprise} + +[Gemini Enterprise can register external A2A agents](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent), +and its managed assistants expose a standard A2A JSON-RPC endpoint. The +endpoint is under the assistant resource, for example +`.../assistants/default_assistant/agents/{id}/a2a`, while the agent card can be +published at a custom URL. Use the generic bearer client and pass that card URL: + +```ts +const client = new A2AClient(geminiEndpoint, googleBearerToken, { + cardUrl: geminiCardUrl, +}); +``` + +The caller needs a Google OAuth bearer with the `discoveryengine.assist` +permission. Custom A2A-agent registration is Pre-GA, so confirm availability in +the target project before exposing it in a workspace picker. See [invoke a +Gemini Enterprise agent](https://docs.cloud.google.com/gemini/enterprise/docs/invoke-agent-a2a) +for the resource URL and IAM requirements. + ## Programmatic workspace invoke {#programmatic-invoke} For agent-native workspaces, prefer the `agentNative` helper when code or a diff --git a/packages/core/docs/content/locales/ar-SA/a2a-protocol.mdx b/packages/core/docs/content/locales/ar-SA/a2a-protocol.mdx index b3c7a2940f3..c25f2358bad 100644 --- a/packages/core/docs/content/locales/ar-SA/a2a-protocol.mdx +++ b/packages/core/docs/content/locales/ar-SA/a2a-protocol.mdx @@ -356,6 +356,107 @@ const response = await callAgent( console.log(response); // "There were 1,247 signups last week..." ``` +## اتصالات الوكلاء المستضافة {#hosted-agent-connections} + +يمكن للوكيل المستضاف المشاركة في A2A عندما يوفّر نقطة نهاية متوافقة مع A2A. +موفّر النموذج أو حزمة تطوير الوكيل وحدهما لا يشكلان نظير A2A. ضع واجهة التطبيق +وإجراءاته وقاعدة بياناته على مضيف التطبيق المعتاد، ثم اربط بيئة تشغيل الوكيل +من خلال محوّل أو نقطة نهاية A2A الخاصة بها. + +يحتوي الاتصال المستضاف على بيانات تعريف نقطة النهاية ووصف للمصادقة. احتفظ بهذه +البيانات مع مورد الوكيل البعيد أو سجل الاتصال. لا تضع رمزًا مميزًا أو سر عميل +في البيان: + +```json +{ + "id": "foundry-research", + "name": "Foundry Research", + "url": "https://example.services.ai.azure.com/agents/foundry-research/endpoint/protocols/a2a", + "cardUrl": "https://example.services.ai.azure.com/agents/foundry-research/endpoint/protocols/a2a/agentCard/v1.0", + "auth": { + "type": "bearer", + "credentialRef": "FOUNDRY_A2A_TOKEN" + } +} +``` + +`url` هو نقطة نهاية الاستدعاء أو عنوان الأساس. يكون `cardUrl` اختياريًا عندما +يستخدم الاكتشاف `/.well-known/agent-card.json`، واستعمله عندما ينشر الموفّر +بطاقة الوكيل في مكان آخر. يقرأ العميل إصدار البروتوكول من بطاقة الوكيل. عندما +تحذف بطاقة الموفّر الإصدار، مرّر `protocolVersion` مباشرة إلى `A2AClient`. +يصف `auth` كيفية حصول كود الخادم على رمز حامل. الأوصاف المدعومة هي +`bearer` مع `credentialRef` محفوظ في الخزنة، أو `oauth-client-credentials` مع +`tokenUrl` و`clientId` و`clientSecretRef` و`scope`. تشير هذه الحقول إلى أسرار، +ولا تحتوي قيم الأسرار نفسها. + +### A2A برمز حامل عام {#generic-bearer-a2a} + +استخدم العميل القياسي عندما يقبل النظير `Authorization: Bearer ...`، ويقدّم +بطاقة متوافقة، ويقبل JSON-RPC الخاص بـ Agent-Native: + +```ts +const client = new A2AClient(endpoint, token, { + cardUrl, + protocolVersion: "0.3", +}); +``` + +حلّ `token` من جانب الخادم عبر اتصال مساحة العمل أو الخزنة. لا ترسله من كود +المتصفح، ولا تكتبه في البيان، ولا تضعه في مطالبة. مفتاح API الثابت هو اعتماد +للنقل إلى الخدمة البعيدة فقط، ولا ينشئ هوية متصل Agent-Native أو يحمل +`approvedActions`. + +### Microsoft Foundry {#microsoft-foundry} + +تشغّل [الوكلاء المستضافون في Azure AI Foundry](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) +كود الوكيل أو الحاوية خلف نقطة نهاية وهوية وتوسعة وحالة جلسة ومراقبة مُدارة. +هذا النموذج مخصص لبيئة تشغيل الوكيل. احتفظ بواجهة Agent-Native وإجراءاته +ونشر PostgreSQL على مضيف التطبيق، واستدع نقطة نهاية Foundry من كود الخادم. + +يمكن لـ Foundry كشف نقطة نهاية A2A من خلال [Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint). +يتطلب ذلك مصادقة Microsoft Entra، ويحتاج المتصل إلى إذن Foundry Agent +Consumer. الإصدار 1.0 هو نقطة نهاية JSON-RPC العامة؛ استخدم عنوان بطاقة +`.../agents/{agent}/endpoint/protocols/a2a/agentCard/v1.0` عند توفره. الإصدار 0.3 هو نقطة النهاية التجريبية التي +تُعاد عند عدم تحديد إصدار. مرّر `protocolVersion` إلى `A2AClient` فقط عندما +تغفل البطاقة الإصدار. لا يوفّر Foundry v1 بث SSE، لذلك يستخدم العميل +`message/send`. يجب على موفّر الرموز الحصول على رمز وصول Entra وتجديده. لا +يكفي `A2A_SECRET` أو مفتاح API ثابت لمصادقة Foundry. + +لمصادقة بيانات اعتماد كيان الخدمة، اطلب رمز Entra بالنطاق +`https://ai.azure.com/.default` وامنح المتصل دور **Foundry Agent Consumer** على +مستوى المشروع أو الوكيل. خزّن سر العميل في الخزنة وأشر إليه من البيان: + +```json +{ + "auth": { + "type": "oauth-client-credentials", + "tokenUrl": "https://login.microsoftonline.com//oauth2/v2.0/token", + "clientId": "", + "clientSecretRef": "FOUNDRY_CLIENT_SECRET", + "scope": "https://ai.azure.com/.default" + } +} +``` + +### Gemini Enterprise {#gemini-enterprise} + +يمكن لـ [Gemini Enterprise تسجيل وكلاء A2A خارجيين](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent)، +كما تعرض المساعدات المُدارة نقطة نهاية JSON-RPC قياسية لـ A2A. توجد نقطة النهاية +تحت مورد المساعد، مثل `.../assistants/default_assistant/agents/{id}/a2a`، بينما +يمكن نشر بطاقة الوكيل في عنوان مخصص. استخدم عميل الحامل العام ومرّر عنوان البطاقة +المخصص: + +```ts +const client = new A2AClient(geminiEndpoint, googleBearerToken, { + cardUrl: geminiCardUrl, +}); +``` + +يحتاج المتصل إلى رمز حامل Google OAuth مع إذن `discoveryengine.assist`. تسجيل +وكلاء A2A المخصصين في مرحلة ما قبل التوافر العام، لذا تحقّق من توفره في المشروع +المستهدف قبل عرضه في منتقي مساحة العمل. راجع [استدعاء وكيل Gemini Enterprise](https://docs.cloud.google.com/gemini/enterprise/docs/invoke-agent-a2a) +لعنوان المورد ومتطلبات IAM. + ## استدعاء مساحة العمل الآلية {#programmatic-invoke} بالنسبة لمساحات العمل الأصلية للوكيل، تفضل مساعد `agentNative` عند استخدام الكود أو diff --git a/packages/core/docs/content/locales/de-DE/a2a-protocol.mdx b/packages/core/docs/content/locales/de-DE/a2a-protocol.mdx index 27f8a3b6c1e..f6fbf66389b 100644 --- a/packages/core/docs/content/locales/de-DE/a2a-protocol.mdx +++ b/packages/core/docs/content/locales/de-DE/a2a-protocol.mdx @@ -358,6 +358,122 @@ const response = await callAgent( console.log(response); // "There were 1,247 signups last week..." ``` +## Verbindungen zu gehosteten Agenten {#hosted-agent-connections} + +Ein gehosteter Agent kann an A2A teilnehmen, wenn er einen kompatiblen +A2A-Endpunkt bereitstellt. Ein Modellanbieter oder ein Agent-SDK allein ist +kein A2A-Peer. Halten Sie UI, Aktionen und Datenbank der App auf dem normalen +App-Host und verbinden Sie die Agent-Laufzeit über einen Adapter oder deren +A2A-Endpunkt. + +Eine gehostete Verbindung enthält Endpunkt-Metadaten und eine +Authentifizierungsbeschreibung. Speichern Sie diese Metadaten beim +Remote-Agent-Ressourceneintrag oder beim Verbindungsdatensatz. Legen Sie niemals +ein Token oder Client-Geheimnis in das Manifest: + +```json +{ + "id": "foundry-research", + "name": "Foundry Research", + "url": "https://example.services.ai.azure.com/agents/foundry-research/endpoint/protocols/a2a", + "cardUrl": "https://example.services.ai.azure.com/agents/foundry-research/endpoint/protocols/a2a/agentCard/v1.0", + "auth": { + "type": "bearer", + "credentialRef": "FOUNDRY_A2A_TOKEN" + } +} +``` + +`url` ist der Aufruf-Endpunkt oder die Basis-URL. `cardUrl` ist optional, wenn +die Erkennung `/.well-known/agent-card.json` verwendet. Verwenden Sie es, wenn +der Anbieter seine Karte an einem anderen Pfad veröffentlicht. Der Client liest +die Protokollversion aus der Agentenkarte. Wenn die Karte des Anbieters sie +nicht enthält, übergeben Sie `protocolVersion` direkt an `A2AClient`. `auth` +beschreibt, wie serverseitiger Code ein Bearer-Token erhält. Unterstützt werden +`bearer` mit einem in der Vault gespeicherten `credentialRef` sowie +`oauth-client-credentials` mit `tokenUrl`, `clientId`, `clientSecretRef` und +`scope`. `credentialRef` und `clientSecretRef` sind Verweise auf Geheimnisse, +keine Geheimniswerte. + +### Generisches Bearer-A2A {#generic-bearer-a2a} + +Verwenden Sie den Standard-Client, wenn der Peer `Authorization: Bearer ...` +akzeptiert, eine kompatible Karte bereitstellt und Agent-Native JSON-RPC +akzeptiert: + +```ts +const client = new A2AClient(endpoint, token, { + cardUrl, + protocolVersion: "0.3", +}); +``` + +Lösen Sie `token` serverseitig über die Workspace-Verbindung oder Vault auf. +Senden Sie es nicht aus Browser-Code, schreiben Sie es nicht in ein Manifest und +fügen Sie es keiner Eingabeaufforderung hinzu. Ein statischer API-Schlüssel ist +nur ein Transport-Credential für den entfernten Dienst. Er stellt keine +Agent-Native-Identität her und überträgt keine `approvedActions`. + +### Microsoft Foundry {#microsoft-foundry} + +[Azure AI Foundry hosted agents](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) +führen Ihren Agent-Code oder Container hinter einem verwalteten Endpunkt mit +Identität, Skalierung, Sitzungsstatus und Observability aus. Dieses Modell dient +der Agent-Laufzeit. Halten Sie Agent-Native UI, Aktionen und die PostgreSQL- +Bereitstellung auf dem App-Host und rufen Sie den Foundry-Endpunkt serverseitig +auf. + +Foundry kann über [Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint) +einen A2A-Endpunkt bereitstellen. Dafür ist Microsoft-Entra-Authentifizierung +erforderlich. Der Aufrufer benötigt außerdem die Foundry-Agent-Consumer- +Berechtigung. Version 1.0 ist der GA-JSON-RPC-Endpunkt; verwenden Sie die +Karten-URL `.../agents/{agent}/endpoint/protocols/a2a/agentCard/v1.0`, wenn sie verfügbar ist. Version 0.3 ist der +Vorschau-Endpunkt, der ohne Versionsauswahl zurückgegeben wird. Übergeben Sie +`protocolVersion` nur dann an `A2AClient`, wenn die Karte die Version auslässt. +Foundry v1 bietet kein SSE-Streaming, daher verwendet der Client +`message/send`. Der Token-Provider muss ein Entra-Zugriffstoken beziehen und +erneuern. `A2A_SECRET` und ein statischer API-Schlüssel erfüllen die Foundry- +Authentifizierung nicht. + +Für die Client-Anmeldeinformationen eines Dienstprinzipals fordern Sie ein +Entra-Token mit dem Bereich `https://ai.azure.com/.default` an und weisen Sie +dem Aufrufer auf Projekt- oder Agent-Ebene die Rolle **Foundry Agent Consumer** +zu. Speichern Sie das Client-Geheimnis im Vault und referenzieren Sie es im +Manifest: + +```json +{ + "auth": { + "type": "oauth-client-credentials", + "tokenUrl": "https://login.microsoftonline.com//oauth2/v2.0/token", + "clientId": "", + "clientSecretRef": "FOUNDRY_CLIENT_SECRET", + "scope": "https://ai.azure.com/.default" + } +} +``` + +### Gemini Enterprise {#gemini-enterprise} + +[Gemini Enterprise kann externe A2A-Agenten registrieren](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent), +und verwaltete Assistenten stellen einen standardmäßigen A2A-JSON-RPC-Endpunkt +bereit. Der Endpunkt liegt unter der Assistant-Ressource, zum Beispiel +`.../assistants/default_assistant/agents/{id}/a2a`; die Agentenkarte kann unter +einer benutzerdefinierten URL liegen. Verwenden Sie den generischen Bearer-Client +und übergeben Sie diese Karten-URL: + +```ts +const client = new A2AClient(geminiEndpoint, googleBearerToken, { + cardUrl: geminiCardUrl, +}); +``` + +Der Aufrufer benötigt ein Google-OAuth-Bearer-Token mit der Berechtigung +`discoveryengine.assist`. Die Registrierung eigener A2A-Agenten ist Pre-GA. +Prüfen Sie daher die Verfügbarkeit im Zielprojekt, bevor Sie sie im +Arbeitsbereichs-Picker anbieten. Siehe [Gemini-Enterprise-Agent aufrufen](https://docs.cloud.google.com/gemini/enterprise/docs/invoke-agent-a2a) +für Ressourcen-URL und IAM-Anforderungen. + ## Programmatischer Arbeitsbereichsaufruf {#programmatic-invoke} Bevorzugen Sie für agentennative Arbeitsbereiche den `agentNative`-Hilfscode oder einen diff --git a/packages/core/docs/content/locales/es-ES/a2a-protocol.mdx b/packages/core/docs/content/locales/es-ES/a2a-protocol.mdx index 47e87446768..fc3025c0948 100644 --- a/packages/core/docs/content/locales/es-ES/a2a-protocol.mdx +++ b/packages/core/docs/content/locales/es-ES/a2a-protocol.mdx @@ -358,6 +358,118 @@ const response = await callAgent( console.log(response); // "There were 1,247 signups last week..." ``` +## Conexiones de agentes alojados {#hosted-agent-connections} + +Un agente alojado puede participar en A2A cuando expone un punto de conexión A2A +compatible. Un proveedor de modelos o un SDK de agentes por sí solo no es un +par A2A. Mantenga la interfaz, las acciones y la base de datos de la aplicación +en su host habitual y conecte el entorno del agente mediante un adaptador o su +punto de conexión A2A. + +Una conexión alojada tiene metadatos del punto de conexión y un descriptor de +autenticación. Guarde estos metadatos con el recurso del agente remoto o el +registro de conexión. Nunca incluya un token ni un secreto de cliente en el +manifiesto: + +```json +{ + "id": "foundry-research", + "name": "Foundry Research", + "url": "https://example.services.ai.azure.com/agents/foundry-research/endpoint/protocols/a2a", + "cardUrl": "https://example.services.ai.azure.com/agents/foundry-research/endpoint/protocols/a2a/agentCard/v1.0", + "auth": { + "type": "bearer", + "credentialRef": "FOUNDRY_A2A_TOKEN" + } +} +``` + +`url` es el punto de conexión de invocación o la URL base. `cardUrl` es opcional +cuando el descubrimiento usa `/.well-known/agent-card.json`. Úselo cuando el +proveedor publique su tarjeta en otra ruta. El cliente lee la versión del +protocolo en la tarjeta del agente. Si la tarjeta del proveedor no la incluye, +pase `protocolVersion` directamente a `A2AClient`. `auth` describe cómo el +código del servidor obtiene un token de portador. Los descriptores admitidos son `bearer` +con un `credentialRef` almacenado en la bóveda, o `oauth-client-credentials` +con `tokenUrl`, `clientId`, `clientSecretRef` y `scope`. `credentialRef` y +`clientSecretRef` son referencias a secretos, nunca los valores de los secretos. + +### A2A con portador genérico {#generic-bearer-a2a} + +Use el cliente estándar cuando el par acepte `Authorization: Bearer ...`, +sirva una tarjeta compatible y acepte JSON-RPC de Agent-Native: + +```ts +const client = new A2AClient(endpoint, token, { + cardUrl, + protocolVersion: "0.3", +}); +``` + +Resuelva `token` en el servidor mediante la conexión del espacio de trabajo o +la bóveda. No lo envíe desde código del navegador, no lo escriba en un +manifiesto ni lo incluya en un prompt. Una clave API estática solo es una +credencial de transporte para el servicio remoto. No establece la identidad del +llamador de Agent-Native ni transporta `approvedActions`. + +### Microsoft Foundry {#microsoft-foundry} + +[Los agentes alojados de Azure AI Foundry](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) +ejecutan su código o contenedor de agente detrás de un punto de conexión, una +identidad, escalado, estado de sesión y observabilidad administrados. Este +modelo aloja el entorno de ejecución del agente. Mantenga la interfaz, las +acciones y el despliegue PostgreSQL de Agent-Native en el host de la aplicación +y llame al punto de conexión de Foundry desde código del servidor. + +Foundry puede exponer un punto de conexión A2A mediante [Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint). +Requiere autenticación de Microsoft Entra y el llamador necesita permiso +Foundry Agent Consumer. La versión 1.0 es el punto de conexión JSON-RPC de GA; +use la URL de tarjeta `.../agents/{agent}/endpoint/protocols/a2a/agentCard/v1.0` cuando esté disponible. La versión +0.3 es el punto de conexión de vista previa que se obtiene cuando no se +selecciona una versión. Pase `protocolVersion` a `A2AClient` solo cuando la +tarjeta omita la versión. Foundry v1 no ofrece transmisión SSE, por lo que el +cliente usa `message/send`. El proveedor de tokens debe obtener y renovar un +token de acceso de Entra. `A2A_SECRET` y una clave API estática no satisfacen la +autenticación de Foundry. + +Para las credenciales de cliente de una entidad de servicio, solicita un token +de Entra con el ámbito `https://ai.azure.com/.default` y asigna al llamador el +rol **Foundry Agent Consumer** en el ámbito del proyecto o del agente. Guarda el +secreto de cliente en el vault y haz referencia a él en el manifiesto: + +```json +{ + "auth": { + "type": "oauth-client-credentials", + "tokenUrl": "https://login.microsoftonline.com//oauth2/v2.0/token", + "clientId": "", + "clientSecretRef": "FOUNDRY_CLIENT_SECRET", + "scope": "https://ai.azure.com/.default" + } +} +``` + +### Gemini Enterprise {#gemini-enterprise} + +[Gemini Enterprise puede registrar agentes A2A externos](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent), +y sus asistentes gestionados exponen un punto de conexión JSON-RPC estándar de +A2A. El punto de conexión está bajo el recurso del asistente, por ejemplo +`.../assistants/default_assistant/agents/{id}/a2a`, mientras que la tarjeta del +agente puede publicarse en una URL personalizada. Use el cliente de portador +genérico y pase esa URL de tarjeta: + +```ts +const client = new A2AClient(geminiEndpoint, googleBearerToken, { + cardUrl: geminiCardUrl, +}); +``` + +El llamador necesita un token de portador OAuth de Google con el permiso +`discoveryengine.assist`. El registro personalizado de agentes A2A es Pre-GA, +así que confirme que está disponible en el proyecto de destino antes de +mostrarlo en el selector del espacio de trabajo. Consulte [invocar un agente de Gemini Enterprise](https://docs.cloud.google.com/gemini/enterprise/docs/invoke-agent-a2a) +para conocer la URL del recurso y los requisitos de IAM. + ## Invocación programática del espacio de trabajo {#programmatic-invoke} Para espacios de trabajo nativos del agente, prefiera el asistente `agentNative` cuando utilice código o diff --git a/packages/core/docs/content/locales/fr-FR/a2a-protocol.mdx b/packages/core/docs/content/locales/fr-FR/a2a-protocol.mdx index 994496640c1..0a53c51d79f 100644 --- a/packages/core/docs/content/locales/fr-FR/a2a-protocol.mdx +++ b/packages/core/docs/content/locales/fr-FR/a2a-protocol.mdx @@ -356,6 +356,98 @@ const response = await callAgent( console.log(response); // "There were 1,247 signups last week..." ``` +## Connexions d'agents hébergés {#hosted-agent-connections} + +Un agent hébergé peut participer à A2A lorsqu'il expose un point de terminaison +A2A compatible. Un fournisseur de modèles ou un SDK d'agents seul ne constitue +pas un pair A2A. Gardez l'interface, les actions et la base de données de +l'application sur son hôte habituel, puis connectez l'environnement d'exécution +de l'agent par un adaptateur ou son point de terminaison A2A. + +Une connexion hébergée comprend les métadonnées du point de terminaison et un +descripteur d'authentification. Conservez ces métadonnées avec la ressource de +l'agent distant ou l'enregistrement de connexion. Ne placez jamais de jeton ou +de secret client dans le manifeste : + +```json +{ + "id": "foundry-research", + "name": "Foundry Research", + "url": "https://example.services.ai.azure.com/a2a", + "cardUrl": "https://example.services.ai.azure.com/agentCard/v0.3", + "auth": { + "type": "bearer", + "credentialRef": "FOUNDRY_A2A_TOKEN" + } +} +``` + +`url` est le point de terminaison d'appel ou l'URL de base. `cardUrl` est +facultatif lorsque la découverte utilise `/.well-known/agent-card.json`. +Utilisez-le lorsque le fournisseur publie sa carte ailleurs. Le client lit la +version du protocole dans la carte de l'agent. Si la carte du fournisseur ne la +contient pas, transmettez `protocolVersion` directement à `A2AClient`. `auth` +décrit comment le code serveur obtient un jeton Bearer. Les descripteurs +pris en charge sont `bearer` avec un `credentialRef` stocké dans le coffre, ou +`oauth-client-credentials` avec `tokenUrl`, `clientId`, `clientSecretRef` et +`scope`. `credentialRef` et `clientSecretRef` sont des références, jamais les +valeurs des secrets. + +### A2A Bearer générique {#generic-bearer-a2a} + +Utilisez le client standard lorsque le pair accepte `Authorization: Bearer ...`, +fournit une carte compatible et accepte le JSON-RPC Agent-Native : + +```ts +const client = new A2AClient(endpoint, token, { + cardUrl, + protocolVersion: "0.3", +}); +``` + +Résolvez `token` côté serveur via la connexion de l'espace de travail ou le +coffre. Ne l'envoyez pas depuis le code du navigateur, ne l'écrivez pas dans un +manifeste et ne l'incluez pas dans une invite. Une clé API statique est +seulement un identifiant de transport pour le service distant. Elle n'établit +pas l'identité de l'appelant Agent-Native et ne transporte pas +`approvedActions`. + +### Microsoft Foundry {#microsoft-foundry} + +[Les agents hébergés Azure AI Foundry](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) +exécutent votre code ou conteneur d'agent derrière un point de terminaison, +une identité, une mise à l'échelle, un état de session et une observabilité +gérés. Ce modèle héberge l'environnement d'exécution de l'agent. Gardez +l'interface, les actions et le déploiement PostgreSQL Agent-Native sur l'hôte +de l'application et appelez le point de terminaison Foundry côté serveur. + +Foundry peut exposer un point de terminaison A2A via [Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint). +Il exige l'authentification Microsoft Entra et l'appelant doit disposer de +l'autorisation Foundry Agent Consumer. Utilisez la carte et le point de +terminaison v0.3 du fournisseur avec `cardUrl` et `protocolVersion` lorsque le +point de terminaison parle le JSON-RPC Agent-Native. Le fournisseur de jetons +doit obtenir et renouveler un jeton d'accès Entra. `A2A_SECRET` et une clé API +statique ne suffisent pas pour l'authentification Foundry. + +### Gemini Enterprise {#gemini-enterprise} + +[Gemini Enterprise peut enregistrer des agents A2A externes](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent), +et son Agent Registry peut publier un point de terminaison proxy. Sa liaison +HTTP+JSON utilise des chemins propres au fournisseur : la découverte utilise +`GET {url}/v1/card`, l'appel `POST {url}/v1/message:send` et le streaming +`POST {url}/v1/message:stream`. Les requêtes utilisent des jetons Bearer +Google OAuth ou ADC et les autorisations Google Cloud IAM. Consultez [Appeler un agent avec son point de terminaison A2A de registre](https://docs.cloud.google.com/gemini/enterprise/docs/invoke-agent-a2a) +pour les exigences de point de terminaison et d'autorisation. + +Ces chemins diffèrent des chemins JSON-RPC Agent-Native +(`/.well-known/agent-card.json` et `/_agent-native/a2a`). N'enregistrez le +point de terminaison qu'après avoir ajouté un adaptateur qui traduit le format +du protocole et obtient puis renouvelle les identifiants Google. Si la cible +expose séparément un point de terminaison A2A standard, utilisez-le avec les +instructions Bearer génériques. Le trafic d'enregistrement Gemini Enterprise +ne passe pas par les règles Agent Gateway. Appliquez donc les règles +d'authentification et d'autorisation propres à la cible. + ## Invocation d'un espace de travail programmatique {#programmatic-invoke} Pour les espaces de travail natifs d'agent, préférez l'assistant `agentNative` lorsque du code ou un diff --git a/packages/core/docs/content/locales/hi-IN/a2a-protocol.mdx b/packages/core/docs/content/locales/hi-IN/a2a-protocol.mdx index 339198fe9d1..29b424f4907 100644 --- a/packages/core/docs/content/locales/hi-IN/a2a-protocol.mdx +++ b/packages/core/docs/content/locales/hi-IN/a2a-protocol.mdx @@ -356,6 +356,90 @@ const response = await callAgent( console.log(response); // "There were 1,247 signups last week..." ``` +## होस्ट किए गए एजेंट कनेक्शन {#hosted-agent-connections} + +होस्ट किया गया एजेंट तब A2A में भाग ले सकता है जब वह संगत A2A एंडपॉइंट उपलब्ध +कराता है। केवल मॉडल प्रदाता या एजेंट SDK A2A पीयर नहीं होता। ऐप का UI, +actions और डेटाबेस सामान्य ऐप होस्ट पर रखें, फिर एजेंट रनटाइम को उसके A2A +एंडपॉइंट या किसी अडैप्टर के माध्यम से जोड़ें। + +होस्ट किए गए कनेक्शन में एंडपॉइंट मेटाडेटा और auth descriptor होता है। यह +मेटाडेटा remote-agent resource या connection record के साथ रखें। manifest में +कभी token या client secret न रखें: + +```json +{ + "id": "foundry-research", + "name": "Foundry Research", + "url": "https://example.services.ai.azure.com/a2a", + "cardUrl": "https://example.services.ai.azure.com/agentCard/v0.3", + "auth": { + "type": "bearer", + "credentialRef": "FOUNDRY_A2A_TOKEN" + } +} +``` + +`url` invocation endpoint या base URL है। जब discovery `/.well-known/agent-card.json` +का उपयोग करता है तब `cardUrl` वैकल्पिक है। जब provider card को किसी दूसरे पथ +पर प्रकाशित करता है तब इसे इस्तेमाल करें। Client agent card से protocol version +पढ़ता है। यदि provider card में version नहीं है, तो `protocolVersion` सीधे +`A2AClient` को दें। `auth` बताता है कि server-side code bearer token कैसे प्राप्त +करेगा। समर्थित descriptor हैं vault-backed +`credentialRef` वाला `bearer`, या `tokenUrl`, `clientId`, `clientSecretRef` और +`scope` वाला `oauth-client-credentials`। `credentialRef` और `clientSecretRef` +secret के references हैं, secret values नहीं। + +### सामान्य bearer A2A {#generic-bearer-a2a} + +जब peer `Authorization: Bearer ...` स्वीकार करता है, संगत card देता है और +Agent-Native JSON-RPC स्वीकार करता है, तब standard client इस्तेमाल करें: + +```ts +const client = new A2AClient(endpoint, token, { + cardUrl, + protocolVersion: "0.3", +}); +``` + +`token` को workspace connection या vault से server-side हल करें। इसे browser +code से न भेजें, manifest में न लिखें और prompt में न रखें। Static API key +केवल remote service के लिए transport credential है। यह Agent-Native caller +identity स्थापित नहीं करती और `approvedActions` नहीं ले जाती। + +### Microsoft Foundry {#microsoft-foundry} + +[Azure AI Foundry hosted agents](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) +आपके agent code या container को managed endpoint, identity, scaling, session +state और observability के पीछे चलाते हैं। यह agent runtime के लिए hosting model +है। Agent-Native UI, actions और PostgreSQL deployment को app host पर रखें और +Foundry endpoint को server-side code से call करें। + +Foundry [Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint) +के माध्यम से A2A endpoint expose कर सकता है। इसके लिए Microsoft Entra auth +चाहिए और caller को Foundry Agent Consumer permission चाहिए। जब endpoint +Agent-Native JSON-RPC बोलता है तब provider के v0.3 card और endpoint के साथ +`cardUrl` और `protocolVersion` का उपयोग करें। Token provider को Entra access +token प्राप्त और refresh करना होगा। `A2A_SECRET` और static API key Foundry auth +के लिए पर्याप्त नहीं हैं। + +### Gemini Enterprise {#gemini-enterprise} + +[Gemini Enterprise external A2A agents को register कर सकता है](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent) +और उसका Agent Registry proxy endpoint प्रकाशित कर सकता है। इसका HTTP+JSON +binding provider-specific paths उपयोग करता है: discovery के लिए `GET {url}/v1/card`, +invocation के लिए `POST {url}/v1/message:send` और streaming के लिए +`POST {url}/v1/message:stream`। Requests Google OAuth या ADC bearer tokens और +Google Cloud IAM का उपयोग करती हैं। Endpoint और permissions के लिए [registry A2A endpoint से agent को call करें](https://docs.cloud.google.com/gemini/enterprise/docs/invoke-agent-a2a)। + +ये paths Agent-Native JSON-RPC paths (`/.well-known/agent-card.json` और +`/_agent-native/a2a`) से अलग हैं। Endpoint को register करने से पहले ऐसा +server-side adapter जोड़ें जो wire format translate करे और Google credentials +प्राप्त तथा refresh करे। यदि target अलग standard A2A endpoint देता है, तो +generic bearer guidance का उपयोग करें। Gemini Enterprise registration traffic +Agent Gateway policies से होकर नहीं जाता, इसलिए target की अपनी auth और +authorization policy लागू करें। + ## प्रोग्रामेटिक वर्कस्पेस इनवोक {#programmatic-invoke} एजेंट-मूल कार्यस्थानों के लिए, कोड या ए के समय `agentNative` सहायक को प्राथमिकता दें diff --git a/packages/core/docs/content/locales/ja-JP/a2a-protocol.mdx b/packages/core/docs/content/locales/ja-JP/a2a-protocol.mdx index 5fd8bdc0076..b3a7c246ddd 100644 --- a/packages/core/docs/content/locales/ja-JP/a2a-protocol.mdx +++ b/packages/core/docs/content/locales/ja-JP/a2a-protocol.mdx @@ -356,6 +356,100 @@ const response = await callAgent( console.log(response); // "There were 1,247 signups last week..." ``` +## ホスト型エージェント接続 {#hosted-agent-connections} + +ホスト型エージェントは、互換性のある A2A エンドポイントを公開すれば +A2A に参加できます。モデルプロバイダーやエージェント SDK だけでは +A2A ピアにはなりません。アプリの UI、アクション、データベースは通常 +のアプリホストに置き、アダプターまたはエージェント自身の A2A エンド +ポイントでランタイムに接続します。 + +ホスト型接続には、エンドポイントのメタデータと認証記述子があります。 +このメタデータは remote-agent リソースまたは接続レコードと一緒に管理 +します。マニフェストにトークンやクライアントシークレットを入れない +でください。 + +```json +{ + "id": "foundry-research", + "name": "Foundry Research", + "url": "https://example.services.ai.azure.com/a2a", + "cardUrl": "https://example.services.ai.azure.com/agentCard/v0.3", + "auth": { + "type": "bearer", + "credentialRef": "FOUNDRY_A2A_TOKEN" + } +} +``` + +`url` は呼び出しエンドポイントまたはベース URL です。検出に +`/.well-known/agent-card.json` を使う場合、`cardUrl` は省略できます。 +プロバイダーが別の場所でカードを公開する場合に指定します。クライアント +はエージェントカードからプロトコルバージョンを読み取ります。カードに +バージョンがない場合は、`protocolVersion` を `A2AClient` に直接渡します。 +`auth` はサーバー側コードが bearer トークンを取得する方法を示します。 +対応する記述子は、Vault に保存した `credentialRef` を +使う `bearer`、または `tokenUrl`、`clientId`、`clientSecretRef`、`scope` +を持つ `oauth-client-credentials` です。`credentialRef` と +`clientSecretRef` はシークレットへの参照であり、値そのものではありま +せん。 + +### 汎用 bearer A2A {#generic-bearer-a2a} + +ピアが `Authorization: Bearer ...` を受け入れ、互換性のあるカードを提供 +し、Agent-Native JSON-RPC を受け入れる場合は標準クライアントを使います。 + +```ts +const client = new A2AClient(endpoint, token, { + cardUrl, + protocolVersion: "0.3", +}); +``` + +`token` はワークスペース接続または Vault からサーバー側で解決します。 +ブラウザーコードから送信したり、マニフェストやプロンプトに書き込ん +だりしないでください。静的 API キーはリモートサービスの転送用認証 +情報にすぎず、Agent-Native の呼び出し元 ID や `approvedActions` を運び +ません。 + +### Microsoft Foundry {#microsoft-foundry} + +[Azure AI Foundry のホスト型エージェント](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) +は、エージェントコードまたはコンテナーを、管理されたエンドポイント、 +ID、スケーリング、セッション状態、可観測性の背後で実行します。これは +エージェントランタイムのホスティングモデルです。Agent-Native の UI、 +アクション、PostgreSQL のデプロイはアプリホストに置き、サーバー側 +コードから Foundry エンドポイントを呼び出します。 + +Foundry は [Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint) +を通じて A2A エンドポイントを公開できます。Microsoft Entra 認証が +必要で、呼び出し元には Foundry Agent Consumer 権限が必要です。エンド +ポイントが Agent-Native JSON-RPC を話す場合は、プロバイダーの v0.3 +カードとエンドポイントに `cardUrl` と `protocolVersion` を指定します。 +トークンプロバイダーは Entra アクセストークンを取得して更新する必要 +があります。`A2A_SECRET` や静的 API キーでは Foundry の認証を満たせ +ません。 + +### Gemini Enterprise {#gemini-enterprise} + +[Gemini Enterprise は外部 A2A エージェントを登録できます](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent)。 +Agent Registry はプロキシエンドポイントも公開できます。その HTTP+JSON +バインディングはプロバイダー固有で、検出は `GET {url}/v1/card`、呼び +出しは `POST {url}/v1/message:send`、ストリーミングは +`POST {url}/v1/message:stream` を使います。リクエストには Google OAuth +または ADC bearer トークンと Google Cloud IAM 権限が必要です。詳しくは +[Registry A2A エンドポイントでエージェントを呼び出す](https://docs.cloud.google.com/gemini/enterprise/docs/invoke-agent-a2a) +を参照してください。 + +これらのパスは Agent-Native JSON-RPC のパス +(`/.well-known/agent-card.json` と `/_agent-native/a2a`) とは異なります。 +ワイヤー形式を変換し、Google の認証情報を取得して更新するサーバー側 +アダプターを追加してからエンドポイントを登録してください。ターゲット +が標準 A2A エンドポイントを別に公開している場合は、汎用 bearer の案内 +を使います。Gemini Enterprise の登録トラフィックは Agent Gateway の +ポリシーを通らないため、ターゲット独自の認証と認可ポリシーを適用して +ください。 + ## プログラムによるワークスペースの呼び出し {#programmatic-invoke} エージェント ネイティブ ワークスペースの場合、コードを記述するときは `agentNative` ヘルパーを優先します。 diff --git a/packages/core/docs/content/locales/ko-KR/a2a-protocol.mdx b/packages/core/docs/content/locales/ko-KR/a2a-protocol.mdx index 855ac120e8d..4701612c166 100644 --- a/packages/core/docs/content/locales/ko-KR/a2a-protocol.mdx +++ b/packages/core/docs/content/locales/ko-KR/a2a-protocol.mdx @@ -356,6 +356,92 @@ const response = await callAgent( console.log(response); // "There were 1,247 signups last week..." ``` +## 호스팅 에이전트 연결 {#hosted-agent-connections} + +호스팅된 에이전트는 호환되는 A2A 엔드포인트를 노출할 때 A2A에 참여할 수 +있습니다. 모델 제공자나 에이전트 SDK만으로는 A2A 피어가 되지 않습니다. +앱의 UI, actions, 데이터베이스는 일반 앱 호스트에 두고 어댑터 또는 자체 +A2A 엔드포인트를 통해 에이전트 런타임을 연결하세요. + +호스팅 연결에는 엔드포인트 메타데이터와 인증 설명자가 있습니다. 이 +메타데이터를 remote-agent 리소스 또는 연결 레코드와 함께 보관하세요. +manifest에 토큰이나 클라이언트 비밀을 넣지 마세요. + +```json +{ + "id": "foundry-research", + "name": "Foundry Research", + "url": "https://example.services.ai.azure.com/a2a", + "cardUrl": "https://example.services.ai.azure.com/agentCard/v0.3", + "auth": { + "type": "bearer", + "credentialRef": "FOUNDRY_A2A_TOKEN" + } +} +``` + +`url`은 호출 엔드포인트 또는 기본 URL입니다. 검색에서 +`/.well-known/agent-card.json`을 사용하면 `cardUrl`은 선택 사항입니다. +제공자가 다른 경로에 카드를 게시할 때 사용하세요. 클라이언트는 에이전트 +카드에서 프로토콜 버전을 읽습니다. 제공자의 카드에 버전이 없으면 +`protocolVersion`을 `A2AClient`에 직접 전달하세요. `auth`는 서버 코드가 +bearer 토큰을 얻는 방법을 설명합니다. 지원되는 설명자는 Vault에 저장된 +`credentialRef`를 사용하는 `bearer`, 또는 `tokenUrl`, `clientId`, +`clientSecretRef`, `scope`를 사용하는 `oauth-client-credentials`입니다. +`credentialRef`와 `clientSecretRef`는 비밀 값이 아니라 비밀에 대한 +참조입니다. + +### 일반 bearer A2A {#generic-bearer-a2a} + +피어가 `Authorization: Bearer ...`를 수락하고 호환 가능한 카드를 제공하며 +Agent-Native JSON-RPC를 수락하면 표준 클라이언트를 사용하세요. + +```ts +const client = new A2AClient(endpoint, token, { + cardUrl, + protocolVersion: "0.3", +}); +``` + +`token`은 workspace connection 또는 Vault에서 서버 측으로 확인하세요. +브라우저 코드에서 보내거나 manifest에 쓰거나 프롬프트에 포함하지 마세요. +정적 API 키는 원격 서비스의 전송 자격 증명일 뿐입니다. Agent-Native +호출자 ID를 설정하거나 `approvedActions`를 전달하지 않습니다. + +### Microsoft Foundry {#microsoft-foundry} + +[Azure AI Foundry 호스팅 에이전트](https://learn.microsoft.com/en-us/azure/foundry/agents/overview)는 +에이전트 코드 또는 컨테이너를 관리되는 엔드포인트, ID, 확장, 세션 상태, +관측성 뒤에서 실행합니다. 이 모델은 에이전트 런타임을 호스팅합니다. +Agent-Native UI, actions 및 PostgreSQL 배포는 앱 호스트에 두고 서버 코드에서 +Foundry 엔드포인트를 호출하세요. + +Foundry는 [Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint)를 +통해 A2A 엔드포인트를 노출할 수 있습니다. Microsoft Entra 인증이 필요하고 +호출자에게 Foundry Agent Consumer 권한이 필요합니다. 엔드포인트가 +Agent-Native JSON-RPC를 사용할 때 제공자의 v0.3 카드와 엔드포인트에 +`cardUrl` 및 `protocolVersion`을 사용하세요. 토큰 제공자는 Entra 액세스 +토큰을 발급받고 갱신해야 합니다. `A2A_SECRET`과 정적 API 키는 Foundry +인증을 충족하지 않습니다. + +### Gemini Enterprise {#gemini-enterprise} + +[Gemini Enterprise는 외부 A2A 에이전트를 등록할 수 있으며](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent), +Agent Registry가 프록시 엔드포인트를 게시할 수 있습니다. HTTP+JSON 바인딩은 +제공자별 경로를 사용합니다. 검색은 `GET {url}/v1/card`, 호출은 +`POST {url}/v1/message:send`, 스트리밍은 `POST {url}/v1/message:stream`입니다. +요청은 Google OAuth 또는 ADC bearer 토큰과 Google Cloud IAM 권한을 +사용합니다. 엔드포인트 및 권한 요구 사항은 [레지스트리 A2A 엔드포인트로 에이전트 호출](https://docs.cloud.google.com/gemini/enterprise/docs/invoke-agent-a2a)을 +참조하세요. + +이 경로는 Agent-Native JSON-RPC 경로 +(`/.well-known/agent-card.json` 및 `/_agent-native/a2a`)와 다릅니다. wire +형식을 변환하고 Google 자격 증명을 발급 및 갱신하는 서버 측 어댑터를 +추가한 후 엔드포인트를 등록하세요. 대상이 별도의 표준 A2A 엔드포인트를 +제공하면 일반 bearer 안내를 사용하세요. Gemini Enterprise 등록 트래픽은 +Agent Gateway 정책을 거치지 않으므로 대상 자체의 인증 및 권한 부여 +정책을 적용하세요. + ## 프로그래밍 방식 작업공간 호출 {#programmatic-invoke} 에이전트 기본 작업 공간의 경우 코드 또는 작업 시 `agentNative` 도우미를 선호하세요. diff --git a/packages/core/docs/content/locales/pt-BR/a2a-protocol.mdx b/packages/core/docs/content/locales/pt-BR/a2a-protocol.mdx index c0e04dc766b..9f734324bd7 100644 --- a/packages/core/docs/content/locales/pt-BR/a2a-protocol.mdx +++ b/packages/core/docs/content/locales/pt-BR/a2a-protocol.mdx @@ -358,6 +358,92 @@ const response = await callAgent( console.log(response); // "There were 1,247 signups last week..." ``` +## Conexões de agentes hospedados {#hosted-agent-connections} + +Um agente hospedado pode participar do A2A quando expõe um endpoint A2A +compatível. Um provedor de modelos ou SDK de agentes sozinho não é um par A2A. +Mantenha a interface, as ações e o banco de dados do app no host normal da +aplicação e conecte o runtime do agente por um adaptador ou pelo endpoint A2A. + +Uma conexão hospedada tem metadados do endpoint e um descritor de autenticação. +Mantenha esses metadados com o recurso do agente remoto ou o registro da +conexão. Nunca coloque um token ou segredo de cliente no manifesto: + +```json +{ + "id": "foundry-research", + "name": "Foundry Research", + "url": "https://example.services.ai.azure.com/a2a", + "cardUrl": "https://example.services.ai.azure.com/agentCard/v0.3", + "auth": { + "type": "bearer", + "credentialRef": "FOUNDRY_A2A_TOKEN" + } +} +``` + +`url` é o endpoint de invocação ou a URL base. `cardUrl` é opcional quando a +descoberta usa `/.well-known/agent-card.json`. Use-o quando o provedor publicar +o card em outro caminho. O cliente lê a versão do protocolo no card do agente. +Se o card do provedor não a incluir, passe `protocolVersion` diretamente para +`A2AClient`. `auth` descreve como o código do servidor obtém um token bearer. Os +descritores aceitos são `bearer` com um `credentialRef` armazenado no vault, ou +`oauth-client-credentials` com `tokenUrl`, `clientId`, `clientSecretRef` e +`scope`. `credentialRef` e `clientSecretRef` são referências a segredos, nunca +os valores dos segredos. + +### A2A bearer genérico {#generic-bearer-a2a} + +Use o cliente padrão quando o par aceitar `Authorization: Bearer ...`, fornecer +um card compatível e aceitar JSON-RPC Agent-Native: + +```ts +const client = new A2AClient(endpoint, token, { + cardUrl, + protocolVersion: "0.3", +}); +``` + +Resolva `token` no servidor por meio da conexão do workspace ou do vault. Não o +envie por código do navegador, não o escreva em um manifesto e não o inclua em +um prompt. Uma chave de API estática é apenas uma credencial de transporte para +o serviço remoto. Ela não estabelece a identidade do chamador Agent-Native nem +carrega `approvedActions`. + +### Microsoft Foundry {#microsoft-foundry} + +[Os agentes hospedados do Azure AI Foundry](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) +executam seu código ou contêiner de agente atrás de endpoint, identidade, +escalonamento, estado de sessão e observabilidade gerenciados. Esse modelo +hospeda o runtime do agente. Mantenha a interface, as ações e a implantação +PostgreSQL do Agent-Native no host do app e chame o endpoint do Foundry pelo +código do servidor. + +O Foundry pode expor um endpoint A2A por meio do [Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint). +Ele exige autenticação do Microsoft Entra, e o chamador precisa da permissão +Foundry Agent Consumer. Use o card e o endpoint v0.3 do provedor com `cardUrl` +e `protocolVersion` quando o endpoint falar JSON-RPC Agent-Native. O provedor de +tokens deve obter e renovar um token de acesso do Entra. `A2A_SECRET` e uma +chave de API estática não atendem à autenticação do Foundry. + +### Gemini Enterprise {#gemini-enterprise} + +[O Gemini Enterprise pode registrar agentes A2A externos](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent), +e o Agent Registry pode publicar um endpoint proxy. A vinculação HTTP+JSON usa +caminhos específicos do provedor: descoberta usa `GET {url}/v1/card`, invocação +usa `POST {url}/v1/message:send` e streaming usa `POST {url}/v1/message:stream`. +As solicitações usam tokens bearer do Google OAuth ou ADC e permissões do Google +Cloud IAM. Consulte [Chamar um agente usando seu endpoint A2A do registro](https://docs.cloud.google.com/gemini/enterprise/docs/invoke-agent-a2a) +para os requisitos de endpoint e permissões. + +Esses caminhos diferem dos caminhos JSON-RPC Agent-Native +(`/.well-known/agent-card.json` e `/_agent-native/a2a`). Registre o endpoint +somente depois de adicionar um adaptador que traduza o formato do protocolo e +obtenha e renove as credenciais do Google. Se o destino expuser um endpoint A2A +padrão separado, use-o com a orientação de bearer genérico. O tráfego de registro +do Gemini Enterprise não passa pelas políticas do Agent Gateway, então aplique a +política de autenticação e autorização do próprio destino. + ## Invocação programática do espaço de trabalho {#programmatic-invoke} Para espaços de trabalho nativos do agente, prefira o auxiliar `agentNative` ao codificar ou um diff --git a/packages/core/docs/content/locales/zh-CN/a2a-protocol.mdx b/packages/core/docs/content/locales/zh-CN/a2a-protocol.mdx index aeee7931b39..16da1fa5d34 100644 --- a/packages/core/docs/content/locales/zh-CN/a2a-protocol.mdx +++ b/packages/core/docs/content/locales/zh-CN/a2a-protocol.mdx @@ -356,6 +356,82 @@ const response = await callAgent( console.log(response); // "There were 1,247 signups last week..." ``` +## 托管代理连接 {#hosted-agent-connections} + +托管代理提供兼容的 A2A 端点后,就可以参与 A2A。单独的模型提供商或代理 +SDK 不是 A2A 对等端。将应用的界面、actions 和数据库保留在通常的应用主 +机上,再通过适配器或代理的 A2A 端点连接代理运行时。 + +托管连接包含端点元数据和身份验证描述。将这些元数据与远程代理资源或连 +接记录一起保存。不要将令牌或客户端密钥放入清单: + +```json +{ + "id": "foundry-research", + "name": "Foundry Research", + "url": "https://example.services.ai.azure.com/a2a", + "cardUrl": "https://example.services.ai.azure.com/agentCard/v0.3", + "auth": { + "type": "bearer", + "credentialRef": "FOUNDRY_A2A_TOKEN" + } +} +``` + +`url` 是调用端点或基础 URL。当发现使用 `/.well-known/agent-card.json` 时, +`cardUrl` 可以省略。提供商在其他路径发布代理卡时请使用它。客户端会从代 +理卡中读取协议版本。如果提供商的代理卡未包含版本,请将 `protocolVersion` +直接传递给 `A2AClient`。`auth` 说明服务器端代码如何获取 bearer 令牌。 +支持的描述是使用保管库中 `credentialRef` 的 +`bearer`,或包含 `tokenUrl`、`clientId`、`clientSecretRef` 和 `scope` 的 +`oauth-client-credentials`。`credentialRef` 和 `clientSecretRef` 是机密 +引用,不是机密值。 + +### 通用 bearer A2A {#generic-bearer-a2a} + +当对等端接受 `Authorization: Bearer ...`、提供兼容的代理卡并接受 +Agent-Native JSON-RPC 时,请使用标准客户端: + +```ts +const client = new A2AClient(endpoint, token, { + cardUrl, + protocolVersion: "0.3", +}); +``` + +在服务器端通过工作区连接或保管库解析 `token`。不要从浏览器代码发送它, +不要写入清单,也不要放入提示词。静态 API 密钥只是远程服务的传输凭据, +不会建立 Agent-Native 调用者身份,也不会携带 `approvedActions`。 + +### Microsoft Foundry {#microsoft-foundry} + +[Azure AI Foundry 托管代理](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) +在托管端点、身份、伸缩、会话状态和可观测性之后运行你的代理代码或容器。 +这是一种代理运行时托管模式。将 Agent-Native 界面、actions 和 PostgreSQL +部署保留在应用主机上,并从服务器端代码调用 Foundry 端点。 + +Foundry 可以通过 [Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint) +公开 A2A 端点。它需要 Microsoft Entra 身份验证,调用者还需要 Foundry +Agent Consumer 权限。当端点使用 Agent-Native JSON-RPC 时,请将提供商的 +v0.3 代理卡和端点与 `cardUrl`、`protocolVersion` 一起使用。令牌提供商 +必须获取并刷新 Entra 访问令牌。`A2A_SECRET` 和静态 API 密钥不能满足 +Foundry 身份验证要求。 + +### Gemini Enterprise {#gemini-enterprise} + +[Gemini Enterprise 可以注册外部 A2A 代理](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent), +Agent Registry 还可以发布代理端点。它的 HTTP+JSON 绑定使用提供商专用的 +路径:发现使用 `GET {url}/v1/card`,调用使用 `POST {url}/v1/message:send`, +流式调用使用 `POST {url}/v1/message:stream`。请求使用 Google OAuth 或 ADC +bearer 令牌以及 Google Cloud IAM 权限。端点和权限要求请参阅[使用注册表 A2A 端点调用代理](https://docs.cloud.google.com/gemini/enterprise/docs/invoke-agent-a2a)。 + +这些路径与 Agent-Native JSON-RPC 路径 +(`/.well-known/agent-card.json` 和 `/_agent-native/a2a`) 不同。只有在添加 +能够转换线协议格式并获取、刷新的 Google 凭据的服务器端适配器后,才注册 +此端点。如果目标另行公开标准 A2A 端点,请按照通用 bearer 指南使用它。 +Gemini Enterprise 的注册流量不会经过 Agent Gateway 策略,因此要应用目 +标自己的身份验证和授权策略。 + ## 编程工作区调用 {#programmatic-invoke} 对于代理本机工作区,在代码或 a 时更喜欢 `agentNative` 帮助器 diff --git a/packages/core/docs/content/locales/zh-TW/a2a-protocol.mdx b/packages/core/docs/content/locales/zh-TW/a2a-protocol.mdx index 938a1ada390..e11a04662a6 100644 --- a/packages/core/docs/content/locales/zh-TW/a2a-protocol.mdx +++ b/packages/core/docs/content/locales/zh-TW/a2a-protocol.mdx @@ -356,6 +356,82 @@ const response = await callAgent( console.log(response); // "There were 1,247 signups last week..." ``` +## 託管代理連線 {#hosted-agent-connections} + +託管代理公開相容的 A2A 端點後即可參與 A2A。單獨的模型供應商或代理 SDK +不是 A2A 對等端。將應用程式的介面、actions 和資料庫保留在一般的應用程 +式主機上,再透過轉接器或代理自己的 A2A 端點連接代理執行階段。 + +託管連線包含端點中繼資料和驗證描述。將這些中繼資料與遠端代理資源或連線 +記錄一起保存。請勿將權杖或用戶端密鑰放入資訊清單: + +```json +{ + "id": "foundry-research", + "name": "Foundry Research", + "url": "https://example.services.ai.azure.com/a2a", + "cardUrl": "https://example.services.ai.azure.com/agentCard/v0.3", + "auth": { + "type": "bearer", + "credentialRef": "FOUNDRY_A2A_TOKEN" + } +} +``` + +`url` 是呼叫端點或基礎 URL。探索使用 `/.well-known/agent-card.json` 時, +`cardUrl` 可以省略。供應商在其他路徑發佈代理卡時請使用它。用戶端會從代 +理卡讀取通訊協定版本。如果供應商的代理卡沒有包含版本,請將 +`protocolVersion` 直接傳給 `A2AClient`。`auth` 說明伺服器端程式碼如何取 +得 bearer 權杖。支援的描述是使用保存庫中 `credentialRef` 的 +`bearer`,或包含 `tokenUrl`、`clientId`、`clientSecretRef` 和 `scope` 的 +`oauth-client-credentials`。`credentialRef` 和 `clientSecretRef` 是機密 +參照,不是機密值。 + +### 通用 bearer A2A {#generic-bearer-a2a} + +當對等端接受 `Authorization: Bearer ...`、提供相容的代理卡並接受 +Agent-Native JSON-RPC 時,請使用標準用戶端: + +```ts +const client = new A2AClient(endpoint, token, { + cardUrl, + protocolVersion: "0.3", +}); +``` + +在伺服器端透過工作區連線或保存庫解析 `token`。請勿從瀏覽器程式碼傳送它, +不要寫入資訊清單,也不要放進提示。靜態 API 金鑰只是遠端服務的傳輸憑證, +不會建立 Agent-Native 呼叫者身分,也不會攜帶 `approvedActions`。 + +### Microsoft Foundry {#microsoft-foundry} + +[Azure AI Foundry 託管代理](https://learn.microsoft.com/en-us/azure/foundry/agents/overview) +會在受管理的端點、身分、擴展、工作階段狀態和可觀測性後執行您的代理程式碼 +或容器。這是代理執行階段的託管模式。將 Agent-Native 介面、actions 和 +PostgreSQL 部署保留在應用程式主機上,並從伺服器端程式碼呼叫 Foundry 端點。 + +Foundry 可以透過 [Agent Service](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/enable-agent-to-agent-endpoint) +公開 A2A 端點。它需要 Microsoft Entra 驗證,呼叫者也需要 Foundry Agent +Consumer 權限。當端點使用 Agent-Native JSON-RPC 時,請將供應商的 v0.3 +代理卡和端點與 `cardUrl`、`protocolVersion` 一起使用。權杖供應商必須取得 +並重新整理 Entra 存取權杖。`A2A_SECRET` 和靜態 API 金鑰不足以通過 Foundry +驗證。 + +### Gemini Enterprise {#gemini-enterprise} + +[Gemini Enterprise 可以註冊外部 A2A 代理](https://docs.cloud.google.com/gemini/enterprise/docs/register-and-manage-an-a2a-agent), +Agent Registry 也可以發佈代理端點。其 HTTP+JSON 繫結使用供應商專用路徑: +探索使用 `GET {url}/v1/card`,呼叫使用 `POST {url}/v1/message:send`,串流 +使用 `POST {url}/v1/message:stream`。要求使用 Google OAuth 或 ADC bearer +權杖和 Google Cloud IAM 權限。端點和權限要求請參閱[使用註冊表 A2A 端點呼叫代理](https://docs.cloud.google.com/gemini/enterprise/docs/invoke-agent-a2a)。 + +這些路徑與 Agent-Native JSON-RPC 路徑 +(`/.well-known/agent-card.json` 和 `/_agent-native/a2a`) 不同。只有在新增可 +轉換線路格式並取得、重新整理 Google 憑證的伺服器端轉接器後,才註冊此端點。 +如果目標另行公開標準 A2A 端點,請依照通用 bearer 指南使用它。Gemini +Enterprise 的註冊流量不會經過 Agent Gateway 政策,因此請套用目標自己的 +驗證和授權政策。 + ## 程式化工作區呼叫 {#programmatic-invoke} 對於 Agent-Native 工作區,在程式碼或 a 時更喜歡 `agentNative` 幫助器 diff --git a/packages/core/src/a2a/client.spec.ts b/packages/core/src/a2a/client.spec.ts index 3d627d407be..a5b7b1896f8 100644 --- a/packages/core/src/a2a/client.spec.ts +++ b/packages/core/src/a2a/client.spec.ts @@ -8,10 +8,13 @@ import { } from "../shared/test-traffic.js"; import { A2AClient, + A2AInsecureEndpointError, + A2AMissingJsonRpcResponseError, A2ATaskTerminalError, A2ATaskTimeoutError, callAction, callAgent, + clearA2ACardCache, signA2AToken, } from "./client.js"; @@ -54,6 +57,7 @@ describe("A2AClient", () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); + clearA2ACardCache(); process.env = originalEnv; }); @@ -1232,7 +1236,6 @@ describe("A2AClient", () => { expect(url).toBe( "https://agent.example/workspace/.well-known/agent-card.json", ); - expect(authorization).toBeNull(); return new Response( JSON.stringify({ name: "Custom Agent", @@ -1865,6 +1868,416 @@ describe("A2AClient", () => { await expect(client.getAgentCard()).rejects.toThrow(/SSRF blocked/i); expect(fetchMock).not.toHaveBeenCalled(); }); + + it("uses an explicit card URL and the card's v1 JSON-RPC interface", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method !== "POST") { + expect(url).toBe("https://agent.test/discovery/card.json"); + return new Response( + JSON.stringify({ + name: "Foundry Agent", + description: "A v1 agent", + version: "2026.09", + capabilities: {}, + skills: [], + supportedInterfaces: [ + { + url: "https://agent.test/foundry/jsonrpc", + protocolBinding: "JSONRPC", + protocolVersion: "1.0", + tenant: "foundry-tenant", + }, + ], + }), + ); + } + + expect(url).toBe("https://agent.test/foundry/jsonrpc"); + expect(new Headers(init.headers).get("A2A-Version")).toBe("1.0"); + const body = JSON.parse(String(init.body)); + expect(body.method).toBe("SendMessage"); + expect(body.params).toMatchObject({ + tenant: "foundry-tenant", + message: { + role: "ROLE_USER", + parts: [{ text: "hello" }], + messageId: expect.any(String), + }, + }); + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: body.id, + result: { + task: { + id: "v1-task", + status: { + state: "TASK_STATE_COMPLETED", + message: { + role: "ROLE_AGENT", + parts: [{ text: "v1 response" }], + }, + }, + }, + }, + }), + { status: 200 }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + new A2AClient("https://agent.test/a2a", undefined, { + cardUrl: "https://agent.test/discovery/card.json", + }).send({ + role: "user", + parts: [{ type: "text", text: "hello" }], + }), + ).resolves.toMatchObject({ + status: { message: { parts: [{ text: "v1 response" }] } }, + }); + }); + + it("keeps configured v1 metadata when a card repeats an explicit endpoint", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method !== "POST") { + expect(url).toBe("https://agent.test/discovery/card.json"); + return new Response( + JSON.stringify({ + name: "Foundry Agent", + description: "A v1 agent", + version: "2026.09", + capabilities: {}, + skills: [], + supportedInterfaces: [ + { + url: "https://agent.test/a2a", + protocolBinding: "JSONRPC", + tenant: "foundry-tenant", + }, + ], + }), + ); + } + + expect(url).toBe("https://agent.test/a2a"); + expect(new Headers(init.headers).get("A2A-Version")).toBe("1.0"); + const body = JSON.parse(String(init.body)); + expect(body.method).toBe("SendMessage"); + expect(body.params.tenant).toBe("foundry-tenant"); + expect(body.params.message.parts[0]).toEqual({ text: "hello" }); + return completedResponse(body, "v1 response"); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + new A2AClient("https://agent.test/a2a", undefined, { + cardUrl: "https://agent.test/discovery/card.json", + protocolVersion: "1.0", + }).send({ + role: "user", + parts: [{ type: "text", text: "hello" }], + }), + ).resolves.toMatchObject({ + status: { message: { parts: [{ text: "v1 response" }] } }, + }); + }); + + it("uses an explicit v1 protocol version without card discovery", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + expect(url).toBe("https://agent.test/a2a"); + expect(new Headers(init?.headers).get("A2A-Version")).toBe("1.0"); + const body = JSON.parse(String(init?.body)); + expect(body.method).toBe("SendMessage"); + return completedResponse(body, "direct v1"); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + new A2AClient("https://agent.test/a2a", undefined, { + protocolVersion: "1.0", + }).send({ + role: "user", + parts: [{ type: "text", text: "hello" }], + }), + ).resolves.toMatchObject({ + status: { message: { parts: [{ text: "direct v1" }] } }, + }); + }); + + it("uses the configured protocol version when a card interface omits it", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method !== "POST") { + expect(url).toBe("https://agent.test/.well-known/agent-card.json"); + return new Response( + JSON.stringify({ + name: "Foundry Agent", + description: "A v1 agent", + version: "2026.09", + capabilities: {}, + skills: [], + supportedInterfaces: [ + { + url: "https://agent.test/a2a", + protocolBinding: "JSONRPC", + }, + ], + }), + ); + } + + expect(url).toBe("https://agent.test/a2a"); + expect(new Headers(init.headers).get("A2A-Version")).toBe("1.0"); + const body = JSON.parse(String(init.body)); + expect(body.method).toBe("SendMessage"); + return completedResponse(body, "configured v1"); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + new A2AClient("https://agent.test", undefined, { + protocolVersion: "1.0", + }).send({ + role: "user", + parts: [{ type: "text", text: "hello" }], + }), + ).resolves.toMatchObject({ + status: { message: { parts: [{ text: "configured v1" }] } }, + }); + }); + + it("preserves the v1 async returnImmediately configuration", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method !== "POST") { + expect(url).toBe("https://agent.test/.well-known/agent-card.json"); + return new Response( + JSON.stringify({ + name: "Foundry Agent", + description: "A v1 agent", + version: "2026.09", + protocolVersion: "1.0", + capabilities: {}, + skills: [], + supportedInterfaces: [ + { + url: "https://agent.test/a2a", + protocolBinding: "JSONRPC", + protocolVersion: "1.0", + }, + ], + }), + ); + } + + const body = JSON.parse(String(init.body)); + expect(body.method).toBe("SendMessage"); + expect(body.params.async).toBeUndefined(); + expect(body.params.configuration).toEqual({ returnImmediately: true }); + return completedResponse(body, "queued"); + }); + vi.stubGlobal("fetch", fetchMock); + + await new A2AClient("https://agent.test", undefined, { + protocolVersion: "1.0", + }).send( + { role: "user", parts: [{ type: "text", text: "hello" }] }, + { async: true }, + ); + }); + + it("normalizes direct v1 status and artifact stream events", async () => { + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method !== "POST") { + return new Response( + JSON.stringify({ + name: "Foundry Agent", + description: "A v1 agent", + version: "2026.09", + protocolVersion: "1.0", + capabilities: { streaming: true }, + skills: [], + supportedInterfaces: [ + { + url: "https://agent.test/a2a", + protocolBinding: "JSONRPC", + protocolVersion: "1.0", + }, + ], + }), + ); + } + + const body = JSON.parse(String(init.body)); + const events = [ + { + jsonrpc: "2.0", + id: body.id, + result: { + statusUpdate: { + taskId: "v1-task", + contextId: "v1-context", + status: { state: "TASK_STATE_WORKING" }, + }, + }, + }, + { + jsonrpc: "2.0", + id: body.id, + result: { + artifactUpdate: { + taskId: "v1-task", + contextId: "v1-context", + artifact: { + name: "answer", + parts: [{ text: "streamed artifact" }], + }, + }, + }, + }, + ]; + return new Response( + events + .map((event) => "data: " + JSON.stringify(event) + "\n\n") + .join(""), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const tasks = []; + for await (const task of new A2AClient("https://agent.test").stream({ + role: "user", + parts: [{ type: "text", text: "hello" }], + })) { + tasks.push(task); + } + + expect(tasks).toHaveLength(2); + expect(tasks[0]).toMatchObject({ + id: "v1-task", + contextId: "v1-context", + status: { state: "working" }, + }); + expect(tasks[1]).toMatchObject({ + id: "v1-task", + artifacts: [{ parts: [{ type: "text", text: "streamed artifact" }] }], + }); + }); + + it("rejects card-advertised cleartext interfaces for credentialed calls", async () => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method !== "POST") { + expect(url).toBe("https://agent.test/discovery/card.json"); + return new Response( + JSON.stringify({ + name: "Insecure Agent", + description: "", + version: "2026.09", + protocolVersion: "1.0", + capabilities: {}, + skills: [], + supportedInterfaces: [ + { + url: "http://agent.test/a2a", + protocolBinding: "JSONRPC", + protocolVersion: "1.0", + }, + ], + }), + ); + } + return completedResponse(JSON.parse(String(init.body)), "unexpected"); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + new A2AClient("https://agent.test", "hosted-token", { + cardUrl: "https://agent.test/discovery/card.json", + }).send({ + role: "user", + parts: [{ type: "text", text: "hello" }], + }), + ).rejects.toBeInstanceOf(A2AInsecureEndpointError); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not retry a streaming transport failure", async () => { + const fetchMock = vi.fn(async () => { + throw new Error("socket closed after acceptance"); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + (async () => { + for await (const _task of new A2AClient( + "https://agent.test/a2a", + "first-token", + { fallbackApiKeys: ["second-token"] }, + ).stream({ + role: "user", + parts: [{ type: "text", text: "hello" }], + })) { + // The request should fail before yielding an event. + } + })(), + ).rejects.toThrow("socket closed after acceptance"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("falls back to message/send when the card does not advertise streaming", async () => { + const methods: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method !== "POST") { + return new Response( + JSON.stringify({ + name: "Non-streaming Agent", + description: "A synchronous agent", + url: "https://agent.test/a2a", + version: "1.0.0", + protocolVersion: "0.3", + capabilities: { streaming: false }, + skills: [], + }), + ); + } + const body = JSON.parse(String(init.body)); + methods.push(body.method); + return completedResponse(body, "fallback response"); + }), + ); + + const tasks = []; + for await (const task of new A2AClient("https://agent.test").stream({ + role: "user", + parts: [{ type: "text", text: "hello" }], + })) { + tasks.push(task); + } + + expect(methods).toEqual(["message/send"]); + expect(tasks[0]).toMatchObject({ + status: { message: { parts: [{ text: "fallback response" }] } }, + }); + }); + + it("raises a typed error when a successful response is missing JSON-RPC", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ id: body.id, result: {} })); + }), + ); + + await expect( + new A2AClient("https://agent.test/a2a").send({ + role: "user", + parts: [{ type: "text", text: "hello" }], + }), + ).rejects.toBeInstanceOf(A2AMissingJsonRpcResponseError); + }); }); function completedResponse(body: any, text: string): Response { diff --git a/packages/core/src/a2a/client.ts b/packages/core/src/a2a/client.ts index dd80fe6b896..8a0e64e390a 100644 --- a/packages/core/src/a2a/client.ts +++ b/packages/core/src/a2a/client.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import * as jose from "jose"; @@ -12,15 +12,19 @@ import { } from "../shared/test-traffic.js"; import { canonicalA2AAudience } from "./audience.js"; import { sanitizeA2ACorrelationMetadata } from "./correlation.js"; +import { RemoteAgentCredentialRejectedError } from "./remote-agent-auth.js"; import type { A2AApprovedAction, A2ACorrelationMetadata, A2ASourceContextReference, A2AReadOnlyActionResult, AgentCard, + A2AProtocolVersion, + Artifact, JsonRpcRequest, JsonRpcResponse, Message, + Part, Task, } from "./types.js"; @@ -71,8 +75,23 @@ const DEFAULT_A2A_POLL_REQUEST_TIMEOUT_MS = 15_000; const DEFAULT_A2A_DISCOVERY_TIMEOUT_MS = 3_000; const MAX_A2A_RPC_ATTEMPTS = 3; const A2A_RPC_RETRY_BASE_MS = 100; +const A2A_CARD_CACHE_TTL_MS = 30_000; export const MAX_A2A_CALLER_RESPONSE_CHARS = 32_768; +interface AgentCardCacheEntry { + card: AgentCard; + expiresAt: number; +} + +const agentCardCache = new Map(); +const agentCardRequests = new Map>(); + +/** Clear card discovery state after a hosted-agent card or auth change. */ +export function clearA2ACardCache(): void { + agentCardCache.clear(); + agentCardRequests.clear(); +} + export class A2ATaskTimeoutError extends Error { readonly taskId: string; readonly lastTask: Task; @@ -92,6 +111,85 @@ export class A2ATaskTimeoutError extends Error { } } +export type A2AProtocolErrorCode = + | "a2a_invalid_json" + | "a2a_missing_jsonrpc" + | "a2a_invalid_jsonrpc" + | "a2a_no_jsonrpc_interface" + | "a2a_insecure_endpoint"; + +/** A response that violates the JSON-RPC envelope required by A2A. */ +export class A2AProtocolError extends Error { + readonly errorCode: A2AProtocolErrorCode; + /** Alias for callers that use the conventional error-code field. */ + readonly code: A2AProtocolErrorCode; + readonly url?: string; + readonly responseText?: string; + + constructor( + message: string, + errorCode: A2AProtocolErrorCode, + options?: { url?: string; responseText?: string }, + ) { + super(message); + this.name = "A2AProtocolError"; + this.errorCode = errorCode; + this.code = errorCode; + this.url = options?.url; + this.responseText = options?.responseText; + } +} + +/** A successful HTTP response had no JSON-RPC response envelope. */ +export class A2AMissingJsonRpcResponseError extends A2AProtocolError { + constructor(url: string, responseText?: string) { + super( + `A2A response from ${url} is missing a JSON-RPC 2.0 envelope`, + "a2a_missing_jsonrpc", + { url, responseText }, + ); + this.name = "A2AMissingJsonRpcResponseError"; + } +} + +/** Invalid JSON-RPC envelopes are kept distinct from transport failures. */ +export class A2AJsonRpcResponseError extends A2AProtocolError { + constructor( + message: string, + options?: { url?: string; responseText?: string }, + ) { + super(message, "a2a_invalid_jsonrpc", options); + this.name = "A2AJsonRpcResponseError"; + } +} + +/** A v1.0 card advertised transports this client cannot invoke. */ +export class A2ANoJsonRpcInterfaceError extends A2AProtocolError { + readonly interfaces: string[]; + + constructor(interfaces: string[]) { + const listed = interfaces.length > 0 ? interfaces.join(", ") : "none"; + super( + `A2A v1.0 card has no JSON-RPC interface (found: ${listed})`, + "a2a_no_jsonrpc_interface", + ); + this.name = "A2ANoJsonRpcInterfaceError"; + this.interfaces = interfaces; + } +} + +/** A credentialed A2A request cannot be sent over cleartext HTTP. */ +export class A2AInsecureEndpointError extends A2AProtocolError { + constructor(url: string) { + super( + `A2A credentialed requests require HTTPS (or loopback HTTP): ${url}`, + "a2a_insecure_endpoint", + { url }, + ); + this.name = "A2AInsecureEndpointError"; + } +} + export type A2ATerminalTaskErrorState = | "failed" | "canceled" @@ -199,14 +297,24 @@ export function shouldPreferGlobalA2ASecret(orgSecret?: string): boolean { return !!process.env.A2A_SECRET?.trim() || !orgSecret; } +interface A2AEndpointCandidate { + url: string; + protocolVersion?: A2AProtocolVersion; + streaming?: boolean; + tenant?: string; +} + export class A2AClient { private baseUrl: string; private apiKey?: string; private apiKeyAttempts: Array; - private endpointCandidates: string[] = []; + private endpointCandidates: A2AEndpointCandidate[] = []; private endpointResolved = false; private requestTimeoutMs?: number; private transportHeaders?: Record; + private cardUrl?: string; + private protocolVersion?: A2AProtocolVersion; + private streaming?: boolean; constructor( baseUrl: string, @@ -215,14 +323,27 @@ export class A2AClient { requestTimeoutMs?: number; fallbackApiKeys?: string[]; transportHeaders?: Record; + /** Explicit agent-card URL when discovery is not at the base URL. */ + cardUrl?: string; + /** Alias accepted for integrations that call this the agent card URL. */ + agentCardUrl?: string; + /** Explicit A2A protocol version for endpoints without a card. */ + protocolVersion?: A2AProtocolVersion; + /** Alias for protocolVersion. */ + a2aVersion?: A2AProtocolVersion; }, ) { const normalized = baseUrl.replace(/\/$/, ""); const explicitEndpoint = splitExplicitA2AEndpoint(normalized); this.baseUrl = explicitEndpoint?.baseUrl ?? normalized; + this.protocolVersion = options?.protocolVersion ?? options?.a2aVersion; if (explicitEndpoint) { - this.endpointCandidates = [explicitEndpoint.endpointUrl]; - this.endpointResolved = true; + this.endpointCandidates = [ + { + url: explicitEndpoint.endpointUrl, + protocolVersion: this.protocolVersion, + }, + ]; } this.apiKey = apiKey; this.apiKeyAttempts = uniqueAuthTokens([ @@ -236,6 +357,11 @@ export class A2AClient { ? { [SYNTHETIC_TRAFFIC_HEADER]: SYNTHETIC_TRAFFIC_BETA_E2E } : {}), }; + const configuredCardUrl = options?.cardUrl ?? options?.agentCardUrl; + this.cardUrl = configuredCardUrl + ? (normalizeUrl(configuredCardUrl, this.baseUrl) ?? configuredCardUrl) + : undefined; + this.endpointResolved = Boolean(explicitEndpoint && !this.cardUrl); } /** @@ -246,7 +372,8 @@ export class A2AClient { await this.ensureEndpointCandidates(); if (this.endpointCandidates.length <= 1) return; - for (const endpoint of this.endpointCandidates) { + for (const candidate of this.endpointCandidates) { + const endpoint = candidate.url; try { const headers = this.transportHeadersFor(endpoint); const res = await ssrfSafeFetch( @@ -264,11 +391,11 @@ export class A2AClient { }, ); if (res.status !== 404 && res.status !== 405) { - this.endpointCandidates = [endpoint]; + this.endpointCandidates = [candidate]; return; } if (res.status === 405) { - this.endpointCandidates = [endpoint]; + this.endpointCandidates = [candidate]; return; } } catch { @@ -280,7 +407,7 @@ export class A2AClient { /** Resolve the card-advertised endpoint without sending an RPC request. */ async resolveEndpointUrl(timeoutMs?: number): Promise { await this.ensureEndpointCandidates(timeoutMs); - const endpoint = this.endpointCandidates[0]; + const endpoint = this.endpointCandidates[0]?.url; if (!endpoint) throw new Error("No A2A endpoint candidates available"); return endpoint; } @@ -294,6 +421,7 @@ export class A2AClient { private headers( apiKey = this.apiKey, targetUrl = this.baseUrl, + protocolVersion = this.protocolVersion, ): Record { const h: Record = { "Content-Type": "application/json", @@ -303,6 +431,9 @@ export class A2AClient { if (apiKey) { h["Authorization"] = `Bearer ${apiKey}`; } + if (protocolVersion) { + h["A2A-Version"] = protocolVersion; + } return h; } @@ -319,12 +450,7 @@ export class A2AClient { params: Record, options?: { requestTimeoutMs?: number; deadlineMs?: number }, ): Promise { - const body: JsonRpcRequest = { - jsonrpc: "2.0", - id: Date.now(), - method, - params, - }; + const requestId = Date.now(); const discoveryTimeoutMs = resolveA2ADiscoveryTimeoutMs( options?.requestTimeoutMs ?? this.requestTimeoutMs, @@ -333,7 +459,19 @@ export class A2AClient { await this.ensureEndpointCandidates(discoveryTimeoutMs); let lastError: Error | null = null; - for (const url of this.endpointCandidates) { + for (const candidate of this.endpointCandidates) { + const url = candidate.url; + const body: JsonRpcRequest = { + jsonrpc: "2.0", + id: requestId, + method: a2aWireMethod(method, candidate.protocolVersion), + params: a2aWireParams( + method, + params, + candidate.protocolVersion, + candidate.tenant, + ), + }; for (let i = 0; i < this.apiKeyAttempts.length; i++) { const maxAttempts = isRetrySafeA2ARpc( method, @@ -361,6 +499,7 @@ export class A2AClient { body, this.apiKeyAttempts[i], requestTimeoutMs, + candidate.protocolVersion, ); } catch (error) { lastError = @@ -391,11 +530,12 @@ export class A2AClient { break; } try { - const parsed = JSON.parse(text) as JsonRpcResponse; - this.endpointCandidates = [url]; + const parsed = parseJsonRpcResponse(text, url); + this.endpointCandidates = [candidate]; this.markApiKeySucceeded(this.apiKeyAttempts[i]); return parsed; } catch (error) { + if (error instanceof A2AProtocolError) throw error; lastError = new Error( `A2A response was not valid JSON: ${ error instanceof Error ? error.message : String(error) @@ -414,6 +554,13 @@ export class A2AClient { } const text = await res.text(); + if (res.status === 401 || res.status === 403) { + lastError = new RemoteAgentCredentialRejectedError({ + status: res.status, + }); + if (i < this.apiKeyAttempts.length - 1) break; + throw lastError; + } lastError = new Error(`A2A request failed (${res.status}): ${text}`); if ( i < this.apiKeyAttempts.length - 1 && @@ -449,13 +596,51 @@ export class A2AClient { * callable. Pass a token to see the invocable set. */ token?: string; + /** Override the configured card URL for this discovery request. */ + cardUrl?: string; }): Promise { + const cardUrl = + options?.cardUrl ?? + this.cardUrl ?? + `${this.baseUrl}/.well-known/agent-card.json`; + const cacheScope = options?.token + ? createHash("sha256") + .update( + `${getRequestContext()?.userEmail ?? ""}\u0000${getRequestContext()?.orgId ?? ""}\u0000${options.token}`, + ) + .digest("hex") + : "anonymous"; + const cacheKey = `${cardUrl}\u0000${cacheScope}`; + const cached = agentCardCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.card; + const inFlight = agentCardRequests.get(cacheKey); + if (inFlight) return inFlight; + const request = this.fetchAgentCard(cardUrl, options); + agentCardRequests.set(cacheKey, request); + try { + const card = await request; + agentCardCache.set(cacheKey, { + card, + expiresAt: Date.now() + A2A_CARD_CACHE_TTL_MS, + }); + return card; + } finally { + agentCardRequests.delete(cacheKey); + } + } + + private async fetchAgentCard( + cardUrl: string, + options?: { timeoutMs?: number; token?: string }, + ): Promise { + assertCredentialedA2AUrl(cardUrl, Boolean(options?.token)); const headers: Record = { - ...this.transportHeadersFor(this.baseUrl), + ...this.transportHeadersFor(cardUrl), ...(options?.token ? { Authorization: `Bearer ${options.token}` } : {}), + ...(this.protocolVersion ? { "A2A-Version": this.protocolVersion } : {}), }; const res = await ssrfSafeFetch( - `${this.baseUrl}/.well-known/agent-card.json`, + cardUrl, { ...(options?.timeoutMs ? { signal: AbortSignal.timeout(options.timeoutMs) } @@ -463,13 +648,19 @@ export class A2AClient { headers, }, { - maxRedirects: 3, + maxRedirects: options?.token ? 0 : 3, allowedPrivateOrigins: workspacePrivateOrigins(), - ...(headers["x-vercel-protection-bypass"] + ...(options?.token || headers["x-vercel-protection-bypass"] ? { followRedirects: false } : {}), }, ); + if ((res.status === 401 || res.status === 403) && options?.token) { + throw new RemoteAgentCredentialRejectedError({ + status: res.status, + tokenUrl: cardUrl, + }); + } if (!res.ok) { throw new Error(`Failed to fetch agent card (${res.status})`); } @@ -524,7 +715,7 @@ export class A2AClient { ); } - return response.result as Task; + return normalizeA2ATaskResult(response.result, response.id); } /** @@ -547,7 +738,7 @@ export class A2AClient { `A2A error (${response.error.code}): ${response.error.message}`, ); } - return response.result as Task; + return normalizeA2ATaskResult(response.result, response.id); } /** @@ -713,29 +904,67 @@ export class A2AClient { message: Message, opts?: { contextId?: string; metadata?: Record }, ): AsyncGenerator { - const body: JsonRpcRequest = { - jsonrpc: "2.0", - id: Date.now(), - method: "message/stream", - params: { - message, + await this.ensureEndpointCandidates(); + const params = { + message, + contextId: opts?.contextId, + metadata: opts?.metadata, + }; + const preferredCandidate = this.endpointCandidates[0]; + if (this.streaming === false || preferredCandidate?.streaming === false) { + yield await this.send(message, { contextId: opts?.contextId, metadata: opts?.metadata, - }, - }; + }); + return; + } - await this.ensureEndpointCandidates(); + const requestId = Date.now(); let res: Response | null = null; let lastError: Error | null = null; + let selectedCandidate: A2AEndpointCandidate | undefined; for (const candidate of this.endpointCandidates) { + const body: JsonRpcRequest = { + jsonrpc: "2.0", + id: requestId, + method: a2aWireMethod("message/stream", candidate.protocolVersion), + params: a2aWireParams( + "message/stream", + params, + candidate.protocolVersion, + candidate.tenant, + ), + }; for (let i = 0; i < this.apiKeyAttempts.length; i++) { - res = await this.postJson(candidate, body, this.apiKeyAttempts[i]); + try { + res = await this.postJson( + candidate.url, + body, + this.apiKeyAttempts[i], + this.requestTimeoutMs, + candidate.protocolVersion, + ); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + // A streaming POST may have reached the receiver before the + // connection failed. Retrying another candidate would submit the + // same message twice without an idempotency key. + throw lastError; + } if (res.ok) { this.endpointCandidates = [candidate]; + selectedCandidate = candidate; this.markApiKeySucceeded(this.apiKeyAttempts[i]); break; } const text = await res.text(); + if (res.status === 401 || res.status === 403) { + lastError = new RemoteAgentCredentialRejectedError({ + status: res.status, + }); + if (i < this.apiKeyAttempts.length - 1) continue; + throw lastError; + } lastError = new Error(`A2A stream failed (${res.status}): ${text}`); if ( i < this.apiKeyAttempts.length - 1 && @@ -752,34 +981,100 @@ export class A2AClient { throw lastError ?? new Error("No A2A endpoint candidates available"); } + const candidate = selectedCandidate ?? this.endpointCandidates[0]; + const contentType = res.headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.includes("application/json")) { + const text = await res.text(); + let response: JsonRpcResponse; + try { + response = parseJsonRpcResponse(text, candidate?.url ?? "A2A endpoint"); + } catch (error) { + if (isA2AStreamingUnsupportedError(error)) { + yield await this.send(message, { + contextId: opts?.contextId, + metadata: opts?.metadata, + }); + return; + } + throw error; + } + if (response.error) { + if (isA2AStreamingUnsupportedError(response.error)) { + yield await this.send(message, { + contextId: opts?.contextId, + metadata: opts?.metadata, + }); + return; + } + throw new Error( + `A2A error (${response.error.code}): ${response.error.message}`, + ); + } + yield normalizeA2ATaskResult(response.result, response.id); + return; + } + const reader = res.body?.getReader(); - if (!reader) throw new Error("No response body"); + if (!reader) { + throw new Error("A2A stream response did not include a readable body"); + } const decoder = new TextDecoder(); let buffer = ""; + let sawEvent = false; while (true) { const { done, value } = await reader.read(); - if (done) break; + if (done) { + buffer += decoder.decode(); + const finalLine = buffer.replace(/\r$/, ""); + if (finalLine.startsWith("data: ")) { + const json = finalLine.slice(6).trim(); + if (json) { + const response = parseJsonRpcResponse( + json, + candidate?.url ?? "A2A endpoint", + ); + if (response.error) { + throw new Error( + `A2A error (${response.error.code}): ${response.error.message}`, + ); + } + sawEvent = true; + yield normalizeA2ATaskResult(response.result, response.id); + } + } + break; + } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const json = line.slice(6).trim(); + const normalizedLine = line.replace(/\r$/, ""); + if (!normalizedLine.startsWith("data: ")) continue; + const json = normalizedLine.slice(6).trim(); if (!json) continue; - const response: JsonRpcResponse = JSON.parse(json); + const response = parseJsonRpcResponse( + json, + candidate?.url ?? "A2A endpoint", + ); if (response.error) { + if (!sawEvent && isA2AStreamingUnsupportedError(response.error)) { + yield await this.send(message, { + contextId: opts?.contextId, + metadata: opts?.metadata, + }); + return; + } throw new Error( `A2A error (${response.error.code}): ${response.error.message}`, ); } - if (response.result) { - yield response.result as Task; - } + sawEvent = true; + yield normalizeA2ATaskResult(response.result, response.id); } } } @@ -788,25 +1083,90 @@ export class A2AClient { if (this.endpointResolved) return; this.endpointResolved = true; - const candidates: string[] = []; - addDefaultEndpointCandidates(candidates, this.baseUrl); + const candidates: A2AEndpointCandidate[] = this.endpointCandidates.length + ? [...this.endpointCandidates] + : []; + if (candidates.length === 0) + addDefaultEndpointCandidates(candidates, this.baseUrl); try { - const card = await this.getAgentCard({ timeoutMs }); - const cardUrl = normalizeUrl(card.url, this.baseUrl); - if (cardUrl) { - const explicitEndpoint = splitExplicitA2AEndpoint(cardUrl); - if (explicitEndpoint) { - candidates.unshift(explicitEndpoint.endpointUrl); - } else { - addDefaultEndpointCandidates(candidates, cardUrl); + const card = await this.getAgentCard({ + timeoutMs, + ...(this.apiKey ? { token: this.apiKey } : {}), + }); + this.streaming = card.capabilities?.streaming; + const interfaceHint = selectJsonRpcInterface( + card, + this.baseUrl, + this.protocolVersion, + ); + const advertisedV1Interfaces = Array.isArray(card.supportedInterfaces) + ? card.supportedInterfaces + : []; + const isV1Card = + card.protocolVersion?.startsWith("1.") || + this.protocolVersion?.startsWith("1.") || + advertisedV1Interfaces.some((entry) => + entry.protocolVersion?.startsWith("1."), + ); + if (isV1Card && !interfaceHint) { + throw new A2ANoJsonRpcInterfaceError( + advertisedV1Interfaces.map((entry) => { + const binding = + typeof entry.protocolBinding === "string" + ? entry.protocolBinding + : "unknown"; + const version = + typeof entry.protocolVersion === "string" + ? entry.protocolVersion + : "unknown"; + return `${binding} ${version}`; + }), + ); + } + if (interfaceHint) { + assertCredentialedA2AUrl( + interfaceHint.url, + hasA2ACredentials(this.apiKeyAttempts, this.transportHeaders), + ); + this.protocolVersion ??= interfaceHint.protocolVersion; + candidates.unshift({ + url: interfaceHint.url, + protocolVersion: interfaceHint.protocolVersion, + streaming: this.streaming, + tenant: interfaceHint.tenant, + }); + } else { + const cardUrl = normalizeUrl(card.url, this.baseUrl); + if (cardUrl) { + const explicitEndpoint = splitExplicitA2AEndpoint(cardUrl); + if (explicitEndpoint) { + candidates.unshift({ + url: explicitEndpoint.endpointUrl, + protocolVersion: card.protocolVersion ?? this.protocolVersion, + streaming: this.streaming, + }); + } else { + addDefaultEndpointCandidates( + candidates, + cardUrl, + card.protocolVersion ?? this.protocolVersion, + this.streaming, + ); + } } } - } catch { + } catch (error) { + if ( + error instanceof A2AProtocolError || + error instanceof RemoteAgentCredentialRejectedError + ) { + throw error; + } // Agent cards are discovery hints. Fall back to conventional endpoints. } - this.endpointCandidates = unique(candidates); + this.endpointCandidates = uniqueEndpointCandidates(candidates); } private async postJson( @@ -814,6 +1174,7 @@ export class A2AClient { body: JsonRpcRequest, apiKey = this.apiKey, requestTimeoutMs = this.requestTimeoutMs, + protocolVersion = this.protocolVersion, ): Promise { const controller = requestTimeoutMs ? new AbortController() : undefined; const timer = @@ -821,7 +1182,9 @@ export class A2AClient { ? setTimeout(() => controller.abort(), requestTimeoutMs) : undefined; try { - const headers = this.headers(apiKey, url); + const headers = this.headers(apiKey, url, protocolVersion); + const credentialed = hasA2ACredentials(apiKey ? [apiKey] : [], headers); + assertCredentialedA2AUrl(url, credentialed); return await ssrfSafeFetch( url, { @@ -831,9 +1194,9 @@ export class A2AClient { signal: controller?.signal, }, { - maxRedirects: 3, + maxRedirects: credentialed ? 0 : 3, allowedPrivateOrigins: workspacePrivateOrigins(), - ...(headers["x-vercel-protection-bypass"] + ...(credentialed || headers["x-vercel-protection-bypass"] ? { followRedirects: false } : {}), }, @@ -881,9 +1244,21 @@ function splitExplicitA2AEndpoint( return null; } -function addDefaultEndpointCandidates(candidates: string[], baseUrl: string) { +function addDefaultEndpointCandidates( + candidates: A2AEndpointCandidate[], + baseUrl: string, + protocolVersion?: A2AProtocolVersion, + streaming?: boolean, +) { const base = baseUrl.replace(/\/$/, ""); - candidates.push(`${base}/_agent-native/a2a`, `${base}/a2a`); + candidates.push( + { + url: `${base}/_agent-native/a2a`, + protocolVersion, + streaming, + }, + { url: `${base}/a2a`, protocolVersion, streaming }, + ); } function normalizeUrl( @@ -900,6 +1275,524 @@ function normalizeUrl( } } +function hasA2ACredentials( + apiKeys: Array, + headers?: Record, +): boolean { + if (apiKeys.some((value) => typeof value === "string" && value.length > 0)) { + return true; + } + return Object.keys(headers ?? {}).some((name) => + /^(authorization|api[-_]key|x-api[-_]key|x-api[-_]token)$/i.test(name), + ); +} + +function assertCredentialedA2AUrl(url: string, credentialed: boolean): void { + if (!credentialed) return; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return; + } + if (parsed.protocol === "https:") return; + if (parsed.protocol === "http:" && isLoopbackHostname(parsed.hostname)) { + return; + } + throw new A2AInsecureEndpointError(url); +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" + ); +} + +function selectJsonRpcInterface( + card: AgentCard, + baseUrl: string, + configuredProtocolVersion?: A2AProtocolVersion, +): { + url: string; + protocolVersion: A2AProtocolVersion; + tenant?: string; +} | null { + const supportedInterfaces = Array.isArray(card.supportedInterfaces) + ? card.supportedInterfaces + : []; + const cardIsV1 = + card.protocolVersion?.startsWith("1.") || + configuredProtocolVersion?.startsWith("1.") || + supportedInterfaces.some((entry) => + entry.protocolVersion?.startsWith("1."), + ); + for (const candidate of supportedInterfaces) { + const entry = candidate as unknown as Record; + const binding = String( + entry.protocolBinding ?? entry.protocol_binding ?? "", + ).toUpperCase(); + if (binding !== "JSONRPC") continue; + const url = normalizeUrl( + typeof entry.url === "string" ? entry.url : undefined, + baseUrl, + ); + if (!url) continue; + const protocolVersion = + typeof entry.protocolVersion === "string" + ? entry.protocolVersion + : typeof entry.protocol_version === "string" + ? entry.protocol_version + : (card.protocolVersion ?? configuredProtocolVersion); + if (!protocolVersion) continue; + return { + url, + protocolVersion, + ...(typeof entry.tenant === "string" ? { tenant: entry.tenant } : {}), + }; + } + + const additionalInterfaces = cardIsV1 + ? [] + : Array.isArray(card.additionalInterfaces) + ? card.additionalInterfaces + : []; + for (const candidate of additionalInterfaces) { + const entry = candidate as unknown as Record; + const binding = String( + entry.protocolBinding ?? entry.protocol_binding ?? entry.transport ?? "", + ).toUpperCase(); + if (binding && binding !== "JSONRPC") continue; + const url = normalizeUrl( + typeof entry.url === "string" ? entry.url : undefined, + baseUrl, + ); + if (!url) continue; + const protocolVersion = + typeof entry.protocolVersion === "string" + ? entry.protocolVersion + : typeof entry.protocol_version === "string" + ? entry.protocol_version + : (card.protocolVersion ?? configuredProtocolVersion ?? "0.3"); + return { + url, + protocolVersion, + ...(typeof entry.tenant === "string" ? { tenant: entry.tenant } : {}), + }; + } + + const preferredTransport = card.preferredTransport?.toUpperCase(); + if ( + !cardIsV1 && + card.url && + (!preferredTransport || preferredTransport === "JSONRPC") + ) { + const url = normalizeUrl(card.url, baseUrl); + if (url) { + return { + url, + protocolVersion: + card.protocolVersion ?? configuredProtocolVersion ?? "0.3", + }; + } + } + return null; +} + +function a2aWireMethod( + method: string, + protocolVersion?: A2AProtocolVersion, +): string { + if (!protocolVersion?.startsWith("1.")) return method; + return ( + ( + { + "message/send": "SendMessage", + "message/stream": "SendStreamingMessage", + "tasks/get": "GetTask", + "tasks/cancel": "CancelTask", + } as Record + )[method] ?? method + ); +} + +function a2aWireParams( + method: string, + params: Record, + protocolVersion?: A2AProtocolVersion, + tenant?: string, +): Record { + if (!protocolVersion?.startsWith("1.")) { + return params; + } + const { async: _async, contextId, message, ...rest } = params; + const wireParams: Record = { + ...rest, + ...(tenant ? { tenant } : {}), + }; + if (message !== undefined) { + wireParams.message = toV1Message(message, contextId); + } else if (contextId !== undefined) { + wireParams.contextId = contextId; + } + if (method === "message/send" && params.async === true) { + const configuration = isRecord(wireParams.configuration) + ? wireParams.configuration + : {}; + wireParams.configuration = { ...configuration, returnImmediately: true }; + } + return wireParams; +} + +function toV1Message( + value: unknown, + contextId?: unknown, +): Record { + const message = isRecord(value) ? value : {}; + const parts = Array.isArray(message.parts) ? message.parts.map(toV1Part) : []; + return { + ...message, + messageId: + typeof message.messageId === "string" && message.messageId + ? message.messageId + : randomUUID(), + ...(contextId !== undefined && message.contextId === undefined + ? { contextId } + : {}), + role: + message.role === "user" + ? "ROLE_USER" + : message.role === "agent" + ? "ROLE_AGENT" + : message.role, + parts, + }; +} + +function toV1Part(value: unknown): Record { + if (!isRecord(value)) return {}; + const { type, file, ...rest } = value; + if (type === "text") return rest; + if (type === "file" && isRecord(file)) { + const { bytes, uri, name, mimeType } = file; + return { + ...(bytes ? { raw: bytes } : uri ? { url: uri } : {}), + ...(name ? { filename: name } : {}), + ...(mimeType ? { mediaType: mimeType } : {}), + }; + } + if (type === "data") return { data: value.data }; + return rest; +} + +function parseJsonRpcResponse(text: string, url: string): JsonRpcResponse { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new A2AProtocolError( + `A2A response was not valid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + "a2a_invalid_json", + { url, responseText: boundProtocolResponseText(text) }, + ); + } + + if (!isRecord(parsed) || !("jsonrpc" in parsed)) { + throw new A2AMissingJsonRpcResponseError( + url, + boundProtocolResponseText(text), + ); + } + if (parsed.jsonrpc !== "2.0") { + throw new A2AJsonRpcResponseError( + `A2A response from ${url} does not use JSON-RPC 2.0`, + { url, responseText: boundProtocolResponseText(text) }, + ); + } + if (!("id" in parsed)) { + throw new A2AJsonRpcResponseError( + `A2A response from ${url} is missing a JSON-RPC id`, + { url, responseText: boundProtocolResponseText(text) }, + ); + } + if (!("result" in parsed) && !("error" in parsed)) { + throw new A2AJsonRpcResponseError( + `A2A response from ${url} has neither a result nor an error`, + { url, responseText: boundProtocolResponseText(text) }, + ); + } + if ( + "error" in parsed && + (!isRecord(parsed.error) || + typeof parsed.error.code !== "number" || + typeof parsed.error.message !== "string") + ) { + throw new A2AJsonRpcResponseError( + `A2A response from ${url} has an invalid JSON-RPC error`, + { url, responseText: boundProtocolResponseText(text) }, + ); + } + return parsed as unknown as JsonRpcResponse; +} + +function boundProtocolResponseText(text: string): string { + return text.length <= 4_096 ? text : `${text.slice(0, 4_096)}…`; +} + +function isA2AStreamingUnsupportedError(error: unknown): boolean { + if (!isRecord(error)) return false; + const code = error.code; + const message = typeof error.message === "string" ? error.message : ""; + return ( + code === -32601 || + code === -32004 || + /stream(?:ing)?[^a-z]*(?:not supported|unsupported|unavailable)/i.test( + message, + ) + ); +} + +function normalizeA2ATaskResult( + value: unknown, + responseId: string | number | null, +): Task { + if (isRecord(value) && isA2ATaskLike(value)) { + return normalizeA2ATask(value); + } + if (isRecord(value) && isRecord(value.task) && isA2ATaskLike(value.task)) { + return normalizeA2ATask(value.task); + } + if (isRecord(value) && isRecord(value.message)) { + const id = + typeof responseId === "string" || typeof responseId === "number" + ? String(responseId) + : `a2a-${Date.now()}`; + return { + id, + status: { + state: "completed", + message: normalizeA2AMessage(value.message), + timestamp: new Date().toISOString(), + }, + }; + } + if (isRecord(value) && value.kind === "status-update") { + if (typeof value.taskId !== "string" || !isRecord(value.status)) { + throw new A2AJsonRpcResponseError( + "A2A status update is missing a task id or status", + ); + } + return normalizeA2ATask({ + id: value.taskId, + ...(typeof value.contextId === "string" + ? { contextId: value.contextId } + : {}), + status: value.status, + }); + } + if (isRecord(value) && isRecord(value.statusUpdate)) { + const update = value.statusUpdate; + if (typeof update.taskId !== "string" || !isRecord(update.status)) { + throw new A2AJsonRpcResponseError( + "A2A status update is missing a task id or status", + ); + } + return normalizeA2ATask({ + id: update.taskId, + ...(typeof update.contextId === "string" + ? { contextId: update.contextId } + : {}), + status: update.status, + }); + } + if (isRecord(value) && isRecord(value.artifactUpdate)) { + const update = value.artifactUpdate; + if ( + typeof update.taskId !== "string" || + !isRecord(update.artifact) || + !Array.isArray(update.artifact.parts) + ) { + throw new A2AJsonRpcResponseError( + "A2A artifact update is missing a task id or artifact", + ); + } + return { + id: update.taskId, + ...(typeof update.contextId === "string" + ? { contextId: update.contextId } + : {}), + status: { + state: "working", + timestamp: new Date().toISOString(), + }, + artifacts: [normalizeA2AArtifact(update.artifact)], + }; + } + if (isRecord(value) && value.kind === "artifact-update") { + if ( + typeof value.taskId !== "string" || + !isRecord(value.artifact) || + !Array.isArray(value.artifact.parts) + ) { + throw new A2AJsonRpcResponseError( + "A2A artifact update is missing a task id or artifact", + ); + } + return { + id: value.taskId, + ...(typeof value.contextId === "string" + ? { contextId: value.contextId } + : {}), + status: { + state: "working", + timestamp: new Date().toISOString(), + }, + artifacts: [normalizeA2AArtifact(value.artifact)], + }; + } + throw new A2AJsonRpcResponseError( + "A2A JSON-RPC result is not a task or message", + ); +} + +function normalizeA2ATask(value: Record): Task { + const status = value.status as Record; + const state = normalizeA2ATaskState(status.state); + return { + ...(value as unknown as Task), + ...(Array.isArray(value.history) + ? { history: value.history.map(normalizeA2AMessage) } + : {}), + ...(Array.isArray(value.artifacts) + ? { artifacts: value.artifacts.map(normalizeA2AArtifact) } + : {}), + status: { + ...(status as unknown as Task["status"]), + state, + ...(isRecord(status.message) + ? { message: normalizeA2AMessage(status.message) } + : {}), + }, + }; +} + +function normalizeA2AMessage(value: Record): Message { + const role = value.role; + return { + ...(value as unknown as Message), + role: + role === "ROLE_USER" || role === "user" + ? "user" + : role === "ROLE_AGENT" || role === "agent" + ? "agent" + : "agent", + parts: Array.isArray(value.parts) + ? value.parts.map(normalizeA2APart) + : Array.isArray(value.content) + ? value.content.map(normalizeA2APart) + : [], + }; +} + +function normalizeA2AArtifact(value: Record): Artifact { + return { + ...(value as Record), + parts: Array.isArray(value.parts) ? value.parts.map(normalizeA2APart) : [], + }; +} + +function normalizeA2APart(value: unknown): Part { + if (!isRecord(value)) return { type: "text" as const, text: "" }; + if (value.type === "text" && typeof value.text === "string") { + return { type: "text", text: value.text }; + } + if (value.type === "file" && isRecord(value.file)) { + return { + type: "file", + file: { + ...(typeof value.file.name === "string" + ? { name: value.file.name } + : {}), + ...(typeof value.file.mimeType === "string" + ? { mimeType: value.file.mimeType } + : {}), + ...(typeof value.file.bytes === "string" + ? { bytes: value.file.bytes } + : {}), + ...(typeof value.file.uri === "string" ? { uri: value.file.uri } : {}), + }, + }; + } + if (value.type === "data" && isRecord(value.data)) { + return { type: "data", data: value.data }; + } + if (value.kind === "text" || typeof value.text === "string") { + return { type: "text" as const, text: String(value.text ?? "") }; + } + if (value.kind === "file" || "raw" in value || "url" in value) { + return { + type: "file" as const, + file: { + ...(typeof value.filename === "string" ? { name: value.filename } : {}), + ...(typeof value.mediaType === "string" + ? { mimeType: value.mediaType } + : {}), + ...(typeof value.raw === "string" ? { bytes: value.raw } : {}), + ...(typeof value.url === "string" ? { uri: value.url } : {}), + }, + }; + } + if (value.kind === "data" || "data" in value) { + return { + type: "data" as const, + data: (value.data ?? {}) as Record, + }; + } + return { type: "text" as const, text: "" }; +} + +function normalizeA2ATaskState(value: unknown): Task["status"]["state"] { + if (typeof value !== "string") { + throw new A2AJsonRpcResponseError("A2A task status has no valid state"); + } + const normalized = value + .replace(/^TASK_STATE_/i, "") + .toLowerCase() + .replace(/_/g, "-"); + if ( + normalized === "submitted" || + normalized === "working" || + normalized === "processing" || + normalized === "completed" || + normalized === "failed" || + normalized === "canceled" || + normalized === "input-required" || + normalized === "auth-required" + ) { + return normalized === "auth-required" ? "input-required" : normalized; + } + if (normalized === "rejected") return "failed"; + throw new A2AJsonRpcResponseError( + `A2A task status has unsupported state: ${value}`, + ); +} + +function isA2ATaskLike(value: Record): boolean { + return ( + typeof value.id === "string" && + isRecord(value.status) && + typeof value.status.state === "string" + ); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + function shouldTryNextEndpoint(status: number): boolean { return status === 404 || status === 405; } @@ -1035,8 +1928,26 @@ function safelyNotifyA2AUpdate( } } -function unique(values: string[]): string[] { - return Array.from(new Set(values)); +function uniqueEndpointCandidates( + candidates: A2AEndpointCandidate[], +): A2AEndpointCandidate[] { + const byUrl = new Map(); + for (const candidate of candidates) { + const existing = byUrl.get(candidate.url); + byUrl.set( + candidate.url, + existing + ? { + url: existing.url, + protocolVersion: + existing.protocolVersion ?? candidate.protocolVersion, + streaming: existing.streaming ?? candidate.streaming, + tenant: existing.tenant ?? candidate.tenant, + } + : candidate, + ); + } + return [...byUrl.values()]; } function uniqueAuthTokens( @@ -1079,6 +1990,14 @@ export async function callAgent( metadata?: Record; /** Trusted server-side headers to carry across the A2A transport. */ transportHeaders?: Record; + /** Explicit agent-card URL when discovery is not at the base URL. */ + cardUrl?: string; + /** Alias accepted by callers that name this the agent card URL. */ + agentCardUrl?: string; + /** Explicit A2A protocol version for targets without a card. */ + protocolVersion?: A2AProtocolVersion; + /** Alias for protocolVersion. */ + a2aVersion?: A2AProtocolVersion; contextId?: string; userEmail?: string; orgDomain?: string; @@ -1162,6 +2081,8 @@ export async function callAgent( const client = new A2AClient(url, apiKeyAttempts[i], { fallbackApiKeys, transportHeaders: opts?.transportHeaders, + cardUrl: opts?.cardUrl ?? opts?.agentCardUrl, + protocolVersion: opts?.protocolVersion ?? opts?.a2aVersion, }); let task: Task; if (useAsync) { @@ -1280,6 +2201,14 @@ export async function callAction( orgSecret?: string; requestTimeoutMs?: number; correlation?: A2ACorrelationMetadata; + /** Explicit agent-card URL when discovery is not at the base URL. */ + cardUrl?: string; + /** Alias accepted by callers that name this the agent card URL. */ + agentCardUrl?: string; + /** Explicit A2A protocol version for targets without a card. */ + protocolVersion?: A2AProtocolVersion; + /** Alias for protocolVersion. */ + a2aVersion?: A2AProtocolVersion; }, ): Promise { const actionName = action.trim(); @@ -1299,6 +2228,8 @@ export async function callAction( const client = new A2AClient(url, discoveryApiKeyAttempts[0], { fallbackApiKeys: discoveryFallbackApiKeys, requestTimeoutMs: opts?.requestTimeoutMs, + cardUrl: opts?.cardUrl ?? opts?.agentCardUrl, + protocolVersion: opts?.protocolVersion ?? opts?.a2aVersion, }); const endpointUrl = await client.resolveEndpointUrl(opts?.requestTimeoutMs); const invocationAudience = normalizeA2AAudience(endpointUrl); @@ -1381,6 +2312,12 @@ function normalizeA2AAudience(url: string): string { } function isA2AAuthRejection(err: unknown): boolean { + if ( + err instanceof RemoteAgentCredentialRejectedError || + (isRecord(err) && err.code === "credential_rejected") + ) { + return true; + } const message = err instanceof Error ? err.message : String(err ?? ""); return /A2A request failed \(401\)|A2A error \(-32001\): (?:Invalid or expired A2A token|Invalid API key|Authentication required)|Invalid or expired A2A token|Invalid API key|Authentication required/i.test( message, diff --git a/packages/core/src/a2a/index.ts b/packages/core/src/a2a/index.ts index 0c9c44b9d22..02dbe30fbcb 100644 --- a/packages/core/src/a2a/index.ts +++ b/packages/core/src/a2a/index.ts @@ -23,7 +23,28 @@ export { } from "./activity.js"; // Client -export { A2AClient, callAction, callAgent, signA2AToken } from "./client.js"; +export { + A2AClient, + A2AJsonRpcResponseError, + A2AMissingJsonRpcResponseError, + A2ANoJsonRpcInterfaceError, + A2AProtocolError, + callAction, + callAgent, + clearA2ACardCache, + signA2AToken, +} from "./client.js"; +export type { A2AProtocolErrorCode } from "./client.js"; +export { + clearRemoteAgentTokenCache, + RemoteAgentAuthError, + RemoteAgentCredentialRejectedError, + resolveRemoteAgentToken, +} from "./remote-agent-auth.js"; +export type { + RemoteAgentAuthErrorCode, + RemoteAgentCredentialContext, +} from "./remote-agent-auth.js"; export { canonicalA2AAudience } from "./audience.js"; export { resolveA2ACallerAuth } from "./caller-auth.js"; export type { A2ACallerAuth } from "./caller-auth.js"; @@ -44,8 +65,11 @@ export type { A2AHandlerResult, A2ASourceContext, AgentCard, + AgentAdditionalInterface, + AgentInterface, AgentSkill, AgentCapabilities, + A2AProtocolVersion, Task, TaskState, TaskStatus, @@ -66,6 +90,12 @@ export type { A2AAgentActivityToolCall, A2AAgentActivityToolStatus, } from "./types.js"; +export type { + RemoteAgentAuth, + RemoteAgentBearerAuth, + RemoteAgentManifest, + RemoteAgentOAuthClientCredentialsAuth, +} from "../resources/metadata.js"; export type { AgentInvocationErrorCode, AgentActionInvocationResult, diff --git a/packages/core/src/a2a/invoke.spec.ts b/packages/core/src/a2a/invoke.spec.ts index c3d755d2672..6c2b4dce910 100644 --- a/packages/core/src/a2a/invoke.spec.ts +++ b/packages/core/src/a2a/invoke.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { AgentInvocationError, @@ -9,6 +9,16 @@ import { type AgentInvocationRuntime, } from "./invoke.js"; +const resolveRemoteAgentTokenMock = vi.hoisted(() => vi.fn()); + +vi.mock("./remote-agent-auth.js", () => ({ + resolveRemoteAgentToken: resolveRemoteAgentTokenMock, +})); + +beforeEach(() => { + resolveRemoteAgentTokenMock.mockReset(); +}); + function runtime( overrides: Partial = {}, ): AgentInvocationRuntime { @@ -96,6 +106,83 @@ describe("invokeAgent", () => { }); }); + it("resolves discovered hosted auth without exposing it in the result", async () => { + const auth = { type: "bearer" as const, credentialRef: "mail-token" }; + const callAgent = vi.fn(async () => "sent"); + const rt = runtime({ + findAgent: vi.fn(async () => ({ + id: "mail", + name: "Mail", + description: "Send and search email", + url: "https://mail.agent-native.test", + color: "#2563eb", + auth, + })), + callAgent, + }); + resolveRemoteAgentTokenMock.mockResolvedValue("resolved-mail-token"); + + const result = await invokeAgent({ + target: "mail", + prompt: "Draft the update", + apiKey: "stale-caller-token", + userEmail: "alice@example.test", + runtime: rt, + }); + + expect(resolveRemoteAgentTokenMock).toHaveBeenCalledWith( + auth, + expect.objectContaining({ userEmail: "alice@example.test" }), + ); + expect(callAgent).toHaveBeenCalledWith( + "https://mail.agent-native.test", + expect.stringContaining("Draft the update"), + expect.objectContaining({ apiKey: "resolved-mail-token" }), + ); + expect(callAgent.mock.calls[0]?.[2]).not.toHaveProperty("userEmail"); + expect(callAgent.mock.calls[0]?.[2]).not.toHaveProperty("orgSecret"); + expect(result.target).not.toHaveProperty("auth"); + }); + + it("uses resolved hosted auth for direct read-only actions", async () => { + const auth = { type: "bearer" as const, credentialRef: "analytics-token" }; + const callAction = vi.fn(async () => ({ + action: "gong-calls", + status: "completed" as const, + output: "ok", + })); + const rt = runtime({ + findAgent: vi.fn(async () => ({ + id: "analytics", + name: "Analytics", + description: "Read calls", + url: "https://analytics.agent-native.test", + color: "#2563eb", + auth, + })), + callAction, + }); + resolveRemoteAgentTokenMock.mockResolvedValue("resolved-analytics-token"); + + const result = await invokeAgentAction({ + target: "analytics", + action: "gong-calls", + input: { company: "Edmunds" }, + apiKey: "stale-caller-token", + userEmail: "alice@example.test", + runtime: rt, + }); + + expect(callAction).toHaveBeenCalledWith( + "https://analytics.agent-native.test", + "gong-calls", + { company: "Edmunds" }, + expect.objectContaining({ apiKey: "resolved-analytics-token" }), + ); + expect(callAction.mock.calls[0]?.[3]).not.toHaveProperty("userEmail"); + expect(result.target).not.toHaveProperty("auth"); + }); + it("invokes one direct read-only action without a delegated prompt", async () => { const callAction = vi.fn(async (_url, action) => ({ action, diff --git a/packages/core/src/a2a/invoke.ts b/packages/core/src/a2a/invoke.ts index 0ec9f592a04..7cbc6b24eec 100644 --- a/packages/core/src/a2a/invoke.ts +++ b/packages/core/src/a2a/invoke.ts @@ -1,12 +1,18 @@ +import type { RemoteAgentAuth } from "../resources/metadata.js"; import { discoverAgents as defaultDiscoverAgents, findAgent as defaultFindAgent, type DiscoveredAgent, } from "../server/agent-discovery.js"; +import { + getRequestOrgId, + getRequestUserEmail, +} from "../server/request-context.js"; import { callAction as defaultCallAction, callAgent as defaultCallAgent, } from "./client.js"; +import { resolveRemoteAgentToken } from "./remote-agent-auth.js"; import type { A2ACorrelationMetadata, A2AReadOnlyActionResult, @@ -46,8 +52,14 @@ export interface ResolvedAgentInvocationTarget { description?: string; url: string; color?: string; + cardUrl?: string; } +const invocationAuthByTarget = new WeakMap< + ResolvedAgentInvocationTarget, + RemoteAgentAuth +>(); + export interface AgentInvocationResult { target: ResolvedAgentInvocationTarget; prompt: string; @@ -87,6 +99,7 @@ export interface InvokeAgentOptions extends ResolveAgentInvocationTargetOptions includeInvocationHint?: boolean; correlation?: A2ACorrelationMetadata; idempotencyKey?: string; + cardUrl?: string; runtime?: Partial; } @@ -100,6 +113,7 @@ export interface InvokeAgentActionOptions extends ResolveAgentInvocationTargetOp orgSecret?: string; requestTimeoutMs?: number; correlation?: A2ACorrelationMetadata; + cardUrl?: string; runtime?: Partial; } @@ -156,14 +170,17 @@ export async function resolveAgentInvocationTarget( ); } - return { + const resolvedTarget: ResolvedAgentInvocationTarget = { kind: "discovered", id: agent.id, name: agent.name, description: agent.description, url: agent.url, color: agent.color, + ...(agent.cardUrl ? { cardUrl: agent.cardUrl } : {}), }; + if (agent.auth) invocationAuthByTarget.set(resolvedTarget, agent.auth); + return resolvedTarget; } /** @@ -193,18 +210,27 @@ export async function invokeAgent( ? prompt : buildAgentInvocationPrompt(prompt, target.url); + const auth = invocationAuthByTarget.get(target); + const authOptions = await resolveInvocationAuth(target, options.userEmail); const callAgent = options.runtime?.callAgent ?? defaultCallAgent; const responseText = await callAgent(target.url, promptToSend, { - apiKey: options.apiKey, + ...(auth + ? { apiKey: authOptions.token } + : { + apiKey: options.apiKey, + userEmail: options.userEmail, + orgDomain: options.orgDomain, + orgSecret: options.orgSecret, + }), contextId: options.contextId, - userEmail: options.userEmail, - orgDomain: options.orgDomain, - orgSecret: options.orgSecret, async: options.async, timeoutMs: options.timeoutMs, pollIntervalMs: options.pollIntervalMs, correlation: options.correlation, idempotencyKey: options.idempotencyKey, + ...((options.cardUrl ?? target.cardUrl) + ? { cardUrl: options.cardUrl ?? target.cardUrl } + : {}), }); return { @@ -245,18 +271,40 @@ export async function invokeAgentAction( runtime: options.runtime, }); const callAction = options.runtime?.callAction ?? defaultCallAction; + const auth = invocationAuthByTarget.get(target); + const authOptions = await resolveInvocationAuth(target, options.userEmail); const result = await callAction(target.url, action, input, { - apiKey: options.apiKey, - userEmail: options.userEmail, - orgDomain: options.orgDomain, - orgSecret: options.orgSecret, + ...(auth + ? { apiKey: authOptions.token } + : { + apiKey: options.apiKey, + userEmail: options.userEmail, + orgDomain: options.orgDomain, + orgSecret: options.orgSecret, + }), requestTimeoutMs: options.requestTimeoutMs, correlation: options.correlation, + ...((options.cardUrl ?? target.cardUrl) + ? { cardUrl: options.cardUrl ?? target.cardUrl } + : {}), }); return { target, action, result }; } +async function resolveInvocationAuth( + target: ResolvedAgentInvocationTarget, + userEmail?: string, +): Promise<{ token?: string }> { + const auth = invocationAuthByTarget.get(target); + if (!auth) return {}; + const token = await resolveRemoteAgentToken(auth, { + userEmail: userEmail || getRequestUserEmail(), + orgId: getRequestOrgId(), + }); + return { token }; +} + export function buildAgentInvocationPrompt( prompt: string, agentUrl: string, diff --git a/packages/core/src/a2a/remote-agent-auth.spec.ts b/packages/core/src/a2a/remote-agent-auth.spec.ts new file mode 100644 index 00000000000..d1eb39d2780 --- /dev/null +++ b/packages/core/src/a2a/remote-agent-auth.spec.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveCredentialMock = vi.hoisted(() => vi.fn()); +const ssrfSafeFetchMock = vi.hoisted(() => vi.fn()); + +vi.mock("../credentials/index.js", () => ({ + resolveCredential: (...args: unknown[]) => resolveCredentialMock(...args), +})); +vi.mock("../extensions/url-safety.js", () => ({ + ssrfSafeFetch: (...args: unknown[]) => ssrfSafeFetchMock(...args), +})); + +import { + clearRemoteAgentTokenCache, + RemoteAgentAuthError, + RemoteAgentCredentialRejectedError, + resolveRemoteAgentToken, +} from "./remote-agent-auth.js"; + +describe("remote hosted-agent auth", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearRemoteAgentTokenCache(); + resolveCredentialMock.mockResolvedValue("client-secret"); + }); + + it("resolves bearer tokens from the current workspace credential scope", async () => { + await expect( + resolveRemoteAgentToken( + { type: "bearer", credentialRef: "FOUNDRY_BEARER" }, + { userEmail: "alice@example.test", orgId: "org-1" }, + ), + ).resolves.toBe("client-secret"); + + expect(resolveCredentialMock).toHaveBeenCalledWith("FOUNDRY_BEARER", { + userEmail: "alice@example.test", + orgId: "org-1", + }); + expect(ssrfSafeFetchMock).not.toHaveBeenCalled(); + }); + + it("requests and caches an OAuth client-credentials token until expiry", async () => { + ssrfSafeFetchMock.mockResolvedValue( + new Response( + JSON.stringify({ access_token: "hosted-token", expires_in: 3_600 }), + { status: 200 }, + ), + ); + const auth = { + type: "oauth-client-credentials" as const, + tokenUrl: "https://login.example.test/oauth/token", + clientId: "client-id", + clientSecretRef: "FOUNDRY_CLIENT_SECRET", + scope: "https://ai.azure.com/.default", + }; + + await expect( + resolveRemoteAgentToken(auth, { userEmail: "alice@example.test" }), + ).resolves.toBe("hosted-token"); + await expect( + resolveRemoteAgentToken(auth, { userEmail: "alice@example.test" }), + ).resolves.toBe("hosted-token"); + + expect(ssrfSafeFetchMock).toHaveBeenCalledTimes(1); + const [, request] = ssrfSafeFetchMock.mock.calls[0] as [ + string, + RequestInit, + ]; + expect(request.method).toBe("POST"); + expect(request.headers).toMatchObject({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }); + expect( + Object.fromEntries(new URLSearchParams(String(request.body))), + ).toEqual({ + grant_type: "client_credentials", + client_id: "client-id", + client_secret: "client-secret", + scope: "https://ai.azure.com/.default", + }); + }); + + it("does not share cached tokens across credential scopes", async () => { + ssrfSafeFetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ access_token: "alice-token", expires_in: 3_600 }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ access_token: "bob-token", expires_in: 3_600 }), + { status: 200 }, + ), + ); + const auth = { + type: "oauth-client-credentials" as const, + tokenUrl: "https://login.example.test/oauth/token", + clientId: "client-id", + clientSecretRef: "FOUNDRY_CLIENT_SECRET", + }; + + await expect( + resolveRemoteAgentToken(auth, { + userEmail: "alice@example.test", + orgId: "org-1", + }), + ).resolves.toBe("alice-token"); + await expect( + resolveRemoteAgentToken(auth, { + userEmail: "bob@example.test", + orgId: "org-2", + }), + ).resolves.toBe("bob-token"); + expect(ssrfSafeFetchMock).toHaveBeenCalledTimes(2); + }); + + it("invalidates a cached token when the vault secret rotates", async () => { + resolveCredentialMock + .mockResolvedValueOnce("old-secret") + .mockResolvedValueOnce("new-secret"); + ssrfSafeFetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ access_token: "old-token", expires_in: 3_600 }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ access_token: "new-token", expires_in: 3_600 }), + { status: 200 }, + ), + ); + const auth = { + type: "oauth-client-credentials" as const, + tokenUrl: "https://login.example.test/oauth/token", + clientId: "client-id", + clientSecretRef: "FOUNDRY_CLIENT_SECRET", + }; + + await expect( + resolveRemoteAgentToken(auth, { userEmail: "alice@example.test" }), + ).resolves.toBe("old-token"); + await expect( + resolveRemoteAgentToken(auth, { userEmail: "alice@example.test" }), + ).resolves.toBe("new-token"); + expect(ssrfSafeFetchMock).toHaveBeenCalledTimes(2); + }); + + it.each([401, 403] as const)( + "surfaces HTTP %s as a typed credential rejection", + async (status) => { + ssrfSafeFetchMock.mockResolvedValue(new Response(null, { status })); + + const error = await resolveRemoteAgentToken( + { + type: "oauth-client-credentials", + tokenUrl: "https://login.example.test/oauth/token", + clientId: "client-id", + clientSecretRef: "FOUNDRY_CLIENT_SECRET", + }, + { userEmail: "alice@example.test" }, + ).catch((value) => value); + + expect(error).toBeInstanceOf(RemoteAgentCredentialRejectedError); + expect(error).toMatchObject({ + name: "RemoteAgentCredentialRejectedError", + code: "credential_rejected", + status, + statusCode: status, + }); + }, + ); + + it("fails before requesting a token when no user-scoped credential is available", async () => { + resolveCredentialMock.mockResolvedValue(undefined); + + const error = await resolveRemoteAgentToken( + { type: "bearer", credentialRef: "FOUNDRY_BEARER" }, + { userEmail: "alice@example.test", orgId: "org-1" }, + ).catch((value) => value); + + expect(error).toBeInstanceOf(RemoteAgentAuthError); + expect(error).toMatchObject({ code: "credential_missing" }); + expect(ssrfSafeFetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/a2a/remote-agent-auth.ts b/packages/core/src/a2a/remote-agent-auth.ts new file mode 100644 index 00000000000..6be5fdcb186 --- /dev/null +++ b/packages/core/src/a2a/remote-agent-auth.ts @@ -0,0 +1,292 @@ +import { createHash } from "node:crypto"; + +import { resolveCredential } from "../credentials/index.js"; +import { ssrfSafeFetch } from "../extensions/url-safety.js"; +import type { + RemoteAgentAuth, + RemoteAgentOAuthClientCredentialsAuth, +} from "../resources/metadata.js"; +import { parseRemoteAgentUrl } from "../resources/metadata.js"; + +export type RemoteAgentAuthErrorCode = + | "credential_missing" + | "credential_rejected" + | "invalid_auth" + | "token_request_failed"; + +export class RemoteAgentAuthError extends Error { + readonly code: RemoteAgentAuthErrorCode; + readonly statusCode?: number; + readonly credentialRef?: string; + readonly tokenUrl?: string; + + constructor(options: { + code: RemoteAgentAuthErrorCode; + message: string; + statusCode?: number; + credentialRef?: string; + tokenUrl?: string; + cause?: unknown; + }) { + super(options.message, { cause: options.cause }); + this.name = "RemoteAgentAuthError"; + this.code = options.code; + this.statusCode = options.statusCode; + this.credentialRef = options.credentialRef; + this.tokenUrl = options.tokenUrl; + } +} + +export class RemoteAgentCredentialRejectedError extends RemoteAgentAuthError { + readonly status: 401 | 403; + + constructor(options: { + status: 401 | 403; + credentialRef?: string; + tokenUrl?: string; + }) { + super({ + code: "credential_rejected", + statusCode: options.status, + credentialRef: options.credentialRef, + tokenUrl: options.tokenUrl, + message: `Hosted agent credentials were rejected (HTTP ${options.status}).`, + }); + this.name = "RemoteAgentCredentialRejectedError"; + this.status = options.status; + } +} + +export interface RemoteAgentCredentialContext { + userEmail?: string; + orgId?: string | null; +} + +interface CachedClientCredentialsToken { + token: string; + expiresAt: number; +} + +const clientCredentialsTokenCache = new Map< + string, + CachedClientCredentialsToken +>(); +const TOKEN_CACHE_SKEW_MS = 30_000; +const TOKEN_REQUEST_TIMEOUT_MS = 10_000; + +/** Clear the in-memory token cache between tests or after a credential rotation. */ +export function clearRemoteAgentTokenCache(): void { + clientCredentialsTokenCache.clear(); +} + +/** + * Resolve the configured auth reference for a connected hosted agent. + * + * The manifest carries only vault reference names. Values are resolved inside + * the current request's user/org scope and never read from process.env. + */ +export async function resolveRemoteAgentToken( + auth: RemoteAgentAuth | undefined, + context: RemoteAgentCredentialContext = {}, +): Promise { + if (!auth) return undefined; + + if (auth.type === "bearer") { + return resolveVaultCredential(auth.credentialRef, context); + } + + return resolveClientCredentialsToken(auth, context); +} + +async function resolveVaultCredential( + credentialRef: string, + context: RemoteAgentCredentialContext, +): Promise { + const ref = credentialRef.trim(); + if (!ref) { + throw new RemoteAgentAuthError({ + code: "invalid_auth", + message: "Hosted agent bearer auth is missing its credential reference.", + }); + } + if (!context.userEmail?.trim()) { + throw new RemoteAgentAuthError({ + code: "credential_missing", + credentialRef: ref, + message: "Hosted agent auth requires an authenticated workspace user.", + }); + } + + const value = await resolveCredential(ref, { + userEmail: context.userEmail, + orgId: context.orgId, + }); + if (!value?.trim()) { + throw new RemoteAgentAuthError({ + code: "credential_missing", + credentialRef: ref, + message: "The configured hosted agent credential is not available.", + }); + } + return value.trim(); +} + +async function resolveClientCredentialsToken( + auth: RemoteAgentOAuthClientCredentialsAuth, + context: RemoteAgentCredentialContext, +): Promise { + const tokenUrl = validateTokenUrl(auth.tokenUrl); + const clientId = auth.clientId.trim(); + const clientSecretRef = auth.clientSecretRef.trim(); + const scope = auth.scope?.trim(); + if (!clientId || !clientSecretRef) { + throw new RemoteAgentAuthError({ + code: "invalid_auth", + message: + "Hosted agent OAuth auth is missing its client ID or client-secret reference.", + }); + } + const clientSecret = await resolveVaultCredential(clientSecretRef, context); + // Vault references are stable across rotations. Include a one-way + // fingerprint of the resolved secret so replacing a value invalidates the + // old access token without retaining the secret itself. + const secretFingerprint = createHash("sha256") + .update(clientSecret) + .digest("hex"); + const cacheKey = [ + tokenUrl, + clientId, + clientSecretRef, + secretFingerprint, + scope ?? "", + context.userEmail?.trim().toLowerCase() ?? "", + context.orgId?.trim() ?? "", + ].join("\u0000"); + const cached = clientCredentialsTokenCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.token; + + const body = new URLSearchParams({ + grant_type: "client_credentials", + client_id: clientId, + client_secret: clientSecret, + ...(scope ? { scope } : {}), + }); + + let response: Response; + try { + response = await ssrfSafeFetch( + tokenUrl, + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS), + }, + { maxRedirects: 0, followRedirects: false }, + ); + } catch (cause) { + throw new RemoteAgentAuthError({ + code: "token_request_failed", + tokenUrl, + message: "The hosted agent token endpoint could not be reached.", + cause, + }); + } + + if (response.status === 401 || response.status === 403) { + throw new RemoteAgentCredentialRejectedError({ + status: response.status, + credentialRef: clientSecretRef, + tokenUrl, + }); + } + if (!response.ok) { + throw new RemoteAgentAuthError({ + code: "token_request_failed", + statusCode: response.status, + tokenUrl, + message: `The hosted agent token endpoint returned HTTP ${response.status}.`, + }); + } + + let responseBody: string; + try { + responseBody = await response.text(); + } catch (cause) { + throw new RemoteAgentAuthError({ + code: "token_request_failed", + tokenUrl, + message: "The hosted agent token endpoint response could not be read.", + cause, + }); + } + + let payload: unknown; + try { + payload = JSON.parse(responseBody); + } catch (cause) { + throw new RemoteAgentAuthError({ + code: "token_request_failed", + tokenUrl, + message: "The hosted agent token endpoint returned invalid JSON.", + cause, + }); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new RemoteAgentAuthError({ + code: "token_request_failed", + tokenUrl, + message: "The hosted agent token endpoint returned an invalid response.", + }); + } + + const accessToken = (payload as Record).access_token; + if (typeof accessToken !== "string" || !accessToken.trim()) { + throw new RemoteAgentAuthError({ + code: "token_request_failed", + tokenUrl, + message: + "The hosted agent token endpoint did not return an access token.", + }); + } + const expiresIn = parseExpiresIn( + (payload as Record).expires_in, + ); + if (expiresIn > 0) { + clientCredentialsTokenCache.set(cacheKey, { + token: accessToken.trim(), + expiresAt: Date.now() + Math.max(1_000, expiresIn - TOKEN_CACHE_SKEW_MS), + }); + } + return accessToken.trim(); +} + +function validateTokenUrl(value: string): string { + const url = parseRemoteAgentUrl(value, { requireHttps: true }); + try { + const parsed = new URL(url ?? ""); + if (parsed.username || parsed.password) throw new Error("credentials"); + return parsed.toString(); + } catch (cause) { + throw new RemoteAgentAuthError({ + code: "invalid_auth", + tokenUrl: value, + message: "Hosted agent auth has an invalid token URL.", + cause, + }); + } +} + +function parseExpiresIn(value: unknown): number { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" + ? Number(value) + : Number.NaN; + if (!Number.isFinite(parsed) || parsed <= 0) return 0; + return parsed * 1_000; +} diff --git a/packages/core/src/a2a/types.ts b/packages/core/src/a2a/types.ts index 9b3367aa07f..5f680907d15 100644 --- a/packages/core/src/a2a/types.ts +++ b/packages/core/src/a2a/types.ts @@ -1,4 +1,4 @@ -// A2A Protocol types (spec v0.3) + framework config types +// A2A Protocol types (spec v0.3/v1.0) + framework config types import type { PublicAgentActionConfig } from "../action.js"; export type { @@ -106,6 +106,7 @@ export interface AgentCapabilities { streaming?: boolean; pushNotifications?: boolean; stateTransitionHistory?: boolean; + extendedAgentCard?: boolean; } export interface AgentSecurityScheme { @@ -116,12 +117,40 @@ export interface AgentSecurityScheme { name?: string; } +/** Protocol version advertised by an A2A agent card. */ +export type A2AProtocolVersion = "0.3" | "1.0" | (string & {}); + +/** A JSON-RPC interface advertised by an A2A v1.0 agent card. */ +export interface AgentInterface { + url: string; + protocolBinding: string; + protocolVersion: A2AProtocolVersion; + tenant?: string; +} + +/** A v0.3 additional interface, retained for card compatibility. */ +export interface AgentAdditionalInterface { + url: string; + transport?: string; + protocolBinding?: string; + protocolVersion?: A2AProtocolVersion; + tenant?: string; +} + export interface AgentCard { name: string; description: string; - url: string; + /** v0.3 primary endpoint. v1.0 cards use supportedInterfaces instead. */ + url?: string; version: string; - protocolVersion: "0.3"; + /** v0.3 protocol selector. */ + protocolVersion?: A2AProtocolVersion; + /** v0.3 primary transport selector. */ + preferredTransport?: string; + /** v0.3 transport alternatives. */ + additionalInterfaces?: AgentAdditionalInterface[]; + /** v1.0 protocol/transport alternatives; the first JSON-RPC entry wins. */ + supportedInterfaces?: AgentInterface[]; capabilities: AgentCapabilities; skills: AgentSkill[]; securitySchemes?: Record; diff --git a/packages/core/src/client/settings/AgentsSection.spec.tsx b/packages/core/src/client/settings/AgentsSection.spec.tsx index 93a858d2f99..daeec2db854 100644 --- a/packages/core/src/client/settings/AgentsSection.spec.tsx +++ b/packages/core/src/client/settings/AgentsSection.spec.tsx @@ -6,7 +6,11 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "../components/ui/tooltip.js"; -import { AgentsSection } from "./AgentsSection.js"; +import { + AgentsSection, + normalizeHostedAgentUrl, + parseHostedAuth, +} from "./AgentsSection.js"; vi.mock("../api-path.js", () => ({ agentNativePath: (path: string) => path, @@ -168,4 +172,54 @@ describe("AgentsSection", () => { "Only workspace owners and admins can connect agents.", ); }); + + it("validates remote URLs and hosted auth references before saving", () => { + expect(normalizeHostedAgentUrl("https://agent.example")).toBe( + "https://agent.example", + ); + expect(normalizeHostedAgentUrl("http://localhost:8085")).toBe( + "http://localhost:8085", + ); + expect(normalizeHostedAgentUrl("http://agent.example")).toBe( + "http://agent.example", + ); + expect( + normalizeHostedAgentUrl("http://agent.example", { requireHttps: true }), + ).toBeUndefined(); + expect( + normalizeHostedAgentUrl("http://127.0.0.1:8085", { + requireHttps: true, + }), + ).toBe("http://127.0.0.1:8085"); + expect( + normalizeHostedAgentUrl("https://user:pass@agent.example"), + ).toBeUndefined(); + + expect( + parseHostedAuth({ type: "bearer", credentialRef: " " }), + ).toBeUndefined(); + expect( + parseHostedAuth({ + type: "oauth-client-credentials", + tokenUrl: "http://localhost:8080/token", + clientId: "client", + clientSecretRef: "secret", + }), + ).toBeUndefined(); + expect( + parseHostedAuth({ + type: "oauth-client-credentials", + tokenUrl: "https://issuer.example/token", + clientId: " client ", + clientSecretRef: " secret ", + scope: " read ", + }), + ).toEqual({ + type: "oauth-client-credentials", + tokenUrl: "https://issuer.example/token", + clientId: "client", + clientSecretRef: "secret", + scope: "read", + }); + }); }); diff --git a/packages/core/src/client/settings/AgentsSection.tsx b/packages/core/src/client/settings/AgentsSection.tsx index 06af01842fb..5e104dac2cb 100644 --- a/packages/core/src/client/settings/AgentsSection.tsx +++ b/packages/core/src/client/settings/AgentsSection.tsx @@ -1,4 +1,8 @@ -import { Skeleton } from "@agent-native/toolkit/design-system"; +import { + Picker, + Skeleton, + TextField, +} from "@agent-native/toolkit/design-system"; import { ButtonBase as ToolkitButtonBase } from "@agent-native/toolkit/ui/button"; import { IconPlus, @@ -22,6 +26,7 @@ import { import { getRemoteAgentIdFromPath, isRemoteAgentPath, + parseRemoteAgentUrl, REMOTE_AGENT_RESOURCE_PREFIX, remoteAgentResourcePath, } from "../../resources/metadata.js"; @@ -31,7 +36,9 @@ import { TooltipContent, TooltipTrigger, } from "../components/ui/tooltip.js"; +import { useT } from "../i18n.js"; import { useOrg, useSyncA2ASecret } from "../org/hooks.js"; +import { NewKeyMenu, type NewKeyOption } from "./NewKeyMenu.js"; interface AgentInfo { id: string; @@ -39,12 +46,34 @@ interface AgentInfo { name: string; url: string; description?: string; + cardUrl?: string; + auth?: HostedAgentAuth; +} + +type HostedAgentAuth = + | { type: "bearer"; credentialRef: string } + | { + type: "oauth-client-credentials"; + tokenUrl: string; + clientId: string; + clientSecretRef: string; + scope?: string; + }; + +type HostedAgentAuthType = "none" | HostedAgentAuth["type"]; + +interface SecretStatusOption { + key: string; + label: string; + status?: string; + source?: string; } /** Wire shape of `GET /_agent-native/agents/probe` (single or batched result). */ interface AgentProbeResult { url: string; reachable: boolean; + cardStatus?: "reachable" | "auth-rejected" | "no-json-rpc"; name?: string; description?: string; securitySchemes?: string[]; @@ -55,6 +84,16 @@ interface AgentProbeResult { error?: string; } +function probeStatus( + result: AgentProbeResult | undefined, +): "reachable" | "auth-rejected" | "no-json-rpc" | null { + if (!result) return null; + if (result.cardStatus) return result.cardStatus; + if (/json.?rpc/i.test(result.error ?? "")) return "no-json-rpc"; + if (result.authorized === false) return "auth-rejected"; + return result.reachable ? "reachable" : null; +} + function describeSkills(publicSkills: number | undefined): string | null { if (publicSkills === undefined) return null; // An empty public skill list only means the card advertises no anonymous- @@ -96,20 +135,239 @@ function describeCheckResult(result: AgentProbeResult): string { return [`Live · ${scheme}`, authText, skills].filter(Boolean).join(" · "); } +export function normalizeHostedAgentUrl( + value: string, + options: { requireHttps?: boolean } = {}, +): string | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + return parseRemoteAgentUrl(trimmed, { + ...(options.requireHttps + ? { allowLoopbackHttp: true, requireHttps: true } + : {}), + }) + ? trimmed + : undefined; +} + +function normalizeHostedAuth( + auth: HostedAgentAuth | undefined, +): HostedAgentAuth | undefined { + if (!auth) return undefined; + if (auth.type === "bearer") { + const credentialRef = auth.credentialRef.trim(); + return credentialRef ? { type: "bearer", credentialRef } : undefined; + } + const tokenUrl = parseRemoteAgentUrl(auth.tokenUrl, { + requireHttps: true, + }); + const clientId = auth.clientId.trim(); + const clientSecretRef = auth.clientSecretRef.trim(); + const scope = auth.scope?.trim(); + if (!tokenUrl || !clientId || !clientSecretRef) return undefined; + return { + type: auth.type, + tokenUrl, + clientId, + clientSecretRef, + ...(scope ? { scope } : {}), + }; +} + +export function parseHostedAuth(value: unknown): HostedAgentAuth | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const candidate = value as Record; + if ( + candidate.type === "bearer" && + typeof candidate.credentialRef === "string" + ) { + return normalizeHostedAuth({ + type: "bearer", + credentialRef: candidate.credentialRef, + }); + } + if ( + candidate.type === "oauth-client-credentials" && + typeof candidate.tokenUrl === "string" && + typeof candidate.clientId === "string" && + typeof candidate.clientSecretRef === "string" && + (candidate.scope === undefined || typeof candidate.scope === "string") + ) { + return normalizeHostedAuth({ + type: candidate.type, + tokenUrl: candidate.tokenUrl, + clientId: candidate.clientId, + clientSecretRef: candidate.clientSecretRef, + ...(typeof candidate.scope === "string" + ? { scope: candidate.scope } + : {}), + }); + } + return undefined; +} + +function HostedAgentFields({ + cardUrl, + onCardUrlChange, + auth, + onAuthChange, + credentialOptions, +}: { + cardUrl: string; + onCardUrlChange: (value: string) => void; + auth?: HostedAgentAuth; + onAuthChange: (value?: HostedAgentAuth) => void; + credentialOptions: NewKeyOption[]; +}) { + const t = useT(); + const authType: HostedAgentAuthType = auth?.type ?? "none"; + const oauthAuth = + auth?.type === "oauth-client-credentials" ? auth : undefined; + const selectedCredentialRef = + auth?.type === "bearer" + ? auth.credentialRef + : auth?.type === "oauth-client-credentials" + ? auth.clientSecretRef + : ""; + + const updateAuthType = (value: string) => { + if (value === "none") { + onAuthChange(undefined); + } else if (value === "bearer") { + onAuthChange({ + type: "bearer", + credentialRef: auth?.type === "bearer" ? auth.credentialRef : "", + }); + } else if (value === "oauth-client-credentials") { + onAuthChange({ + type: "oauth-client-credentials", + tokenUrl: + auth?.type === "oauth-client-credentials" ? auth.tokenUrl : "", + clientId: + auth?.type === "oauth-client-credentials" ? auth.clientId : "", + clientSecretRef: + auth?.type === "oauth-client-credentials" ? auth.clientSecretRef : "", + scope: + auth?.type === "oauth-client-credentials" ? (auth.scope ?? "") : "", + }); + } + }; + + const updateCredentialRef = (value: string) => { + if (auth?.type === "bearer") { + onAuthChange({ ...auth, credentialRef: value }); + } else if (auth?.type === "oauth-client-credentials") { + onAuthChange({ ...auth, clientSecretRef: value }); + } + }; + + const credentialLabel = selectedCredentialRef + ? (credentialOptions.find((option) => option.key === selectedCredentialRef) + ?.label ?? selectedCredentialRef) + : t("agentChat.agents.chooseCredential"); + + return ( +
+ + {t("agentChat.agents.hostedAgent")} + +
+ + updateAuthType(String(value))} + aria-label={t("agentChat.agents.authType")} + options={[ + { value: "none", label: t("agentChat.agents.authNone") }, + { value: "bearer", label: t("agentChat.agents.authBearer") }, + { + value: "oauth-client-credentials", + label: t("agentChat.agents.authClientCredentials"), + }, + ]} + className="text-[11px]" + /> + {authType !== "none" && ( +
+ + {credentialLabel} + + updateCredentialRef(option.key)} + onCustom={(name) => { + if (name) updateCredentialRef(name); + }} + triggerClassName="shrink-0" + /> +
+ )} + {oauthAuth && ( + <> + + onAuthChange({ ...oauthAuth, tokenUrl: value }) + } + aria-label={t("agentChat.agents.tokenUrl")} + placeholder={t("agentChat.agents.tokenUrl")} + className="w-full text-[11px]" + /> + + onAuthChange({ ...oauthAuth, clientId: value }) + } + aria-label={t("agentChat.agents.clientId")} + placeholder={t("agentChat.agents.clientId")} + className="w-full text-[11px]" + /> + onAuthChange({ ...oauthAuth, scope: value })} + aria-label={t("agentChat.agents.scope")} + placeholder={t("agentChat.agents.scope")} + className="w-full text-[11px]" + /> + + )} +
+
+ ); +} + function AgentEditPopover({ agent, + credentialOptions, onSave, onDelete, onClose, }: { agent: AgentInfo; - onSave: (agent: AgentInfo) => void; + credentialOptions: NewKeyOption[]; + onSave: (agent: AgentInfo) => Promise | void; onDelete: (id: string) => void; onClose: () => void; }) { const [name, setName] = useState(agent.name); const [url, setUrl] = useState(agent.url); const [description, setDescription] = useState(agent.description ?? ""); + const [cardUrl, setCardUrl] = useState(agent.cardUrl ?? ""); + const [auth, setAuth] = useState(agent.auth); + const [saveError, setSaveError] = useState(null); const popoverRef = useRef(null); useEffect(() => { @@ -125,14 +383,23 @@ function AgentEditPopover({ return () => document.removeEventListener("mousedown", handleClick); }, [onClose]); - const handleSave = () => { + const handleSave = async () => { if (!name.trim() || !url.trim()) return; - onSave({ - ...agent, - name: name.trim(), - url: url.trim(), - description: description.trim() || undefined, - }); + try { + await onSave({ + ...agent, + name: name.trim(), + url: url.trim(), + description: description.trim() || undefined, + cardUrl: cardUrl.trim() || undefined, + auth, + }); + setSaveError(null); + } catch (error) { + setSaveError( + error instanceof Error ? error.message : "Could not save agent", + ); + } }; return ( @@ -145,7 +412,7 @@ function AgentEditPopover({ value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") handleSave(); + if (e.key === "Enter") void handleSave(); if (e.key === "Escape") onClose(); }} className="w-full rounded border border-border bg-background px-2 py-1 text-[11px] text-foreground outline-none placeholder:text-muted-foreground/50 focus:ring-1 focus:ring-accent" @@ -155,7 +422,7 @@ function AgentEditPopover({ value={url} onChange={(e) => setUrl(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") handleSave(); + if (e.key === "Enter") void handleSave(); if (e.key === "Escape") onClose(); }} className="w-full rounded border border-border bg-background px-2 py-1 text-[11px] text-foreground outline-none placeholder:text-muted-foreground/50 focus:ring-1 focus:ring-accent" @@ -165,12 +432,22 @@ function AgentEditPopover({ value={description} onChange={(e) => setDescription(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") handleSave(); + if (e.key === "Enter") void handleSave(); if (e.key === "Escape") onClose(); }} className="w-full rounded border border-border bg-background px-2 py-1 text-[11px] text-foreground outline-none placeholder:text-muted-foreground/50 focus:ring-1 focus:ring-accent" placeholder="Description (optional)" /> + + {saveError && ( +

{saveError}

+ )}
{check.status === "error" && ( -

+

{check.message}

@@ -413,7 +741,7 @@ function AgentAddPopover({ className={`flex items-start gap-1 text-[10px] ${ unreachable || unauthorized ? "text-amber-600 dark:text-amber-400" - : "text-green-600 dark:text-green-500" + : "text-primary" }`} > {unreachable || unauthorized ? ( @@ -485,6 +813,16 @@ function AgentAddPopover({ className="w-full rounded border border-border bg-background px-2 py-1 text-[11px] text-foreground outline-none placeholder:text-muted-foreground/50 focus:ring-1 focus:ring-accent" placeholder="Description (optional)" /> + { + setCardUrl(value); + setCheck({ status: "idle" }); + }} + auth={auth} + onAuthChange={setAuth} + credentialOptions={credentialOptions} + />
+ {(() => { + const status = probeStatus(probe); + if (!status) return null; + const label = + status === "reachable" + ? t("agentChat.agents.statusReachable") + : status === "auth-rejected" + ? t("agentChat.agents.statusAuthRejected") + : t("agentChat.agents.statusNoJsonRpc"); + return ( + + {label} + + ); + })()} {canManageSharedAgents ? (