diff --git a/frontend/README.md b/frontend/README.md index 5363d9e23..f2c4aa284 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -3,6 +3,17 @@ A React web UI for VeADK / Google ADK agents. It talks to the standard ADK API server that `veadk frontend` launches — no separate backend. +## Per-MPA collaborating Agents + +Studio administrators can open **Collaborating Agents** under **Agents → MPA → selected agent**, in the tab below **Basic information**. Add, edit, enable/disable, check and remove external +Agent registrations. Connection checks validate Agent Cards only; task dispatch is +not implemented here. Credentials are Runtime environment references, not browser +secret input. See the [configuration and handoff contract](../specs/mpa-collaborating-agents/README.md). + +Studio 管理员可进入「智能体 → MPA 智能体 → 选中智能体」,在「基本信息」下的 Tab 打开「协作 Agent」,增删改查、启停并验证 +Agent Card。凭据直接输入,默认隐藏且可切换显示;保存后不回显,编辑留空保留,后端自动加密持久化。启用控制派发资格,停用保留配置;本次不包含任务派发。需同步升级 Runtime。 +参见[配置及交接说明](../specs/mpa-collaborating-agents/README.zh.md)。 + ## Release notifications MPA basic information includes an editable maximum instance count, defaulting to @@ -1850,9 +1861,9 @@ MPA name validation highlights invalid input and its message in the theme danger MPA 名称校验使用主题错误色显示非法输入及错误提示,并提供合法名称示例;名称修正前“下一步”明显置灰。共享 Button 的禁用态统一降低透明度,加载态保留原有外观。 -MPA Agent details currently expose only Basic information. Other tabs and the Profile configuration action are hidden; this page does not query Profile status or submit Profile operations. General Agent pages and chat session protocols are unchanged. +MPA Agent details expose Basic information and, for administrators with a Runtime, Collaborating Agents. Other tabs and the Profile configuration action are hidden; this page does not query Profile status or submit Profile operations. General Agent pages and chat session protocols are unchanged. -MPA 智能体详情当前仅显示基本信息。其他页面及 Profile 配置操作隐藏;本页不查询 Profile 状态或提交 Profile 操作。普通智能体页面与聊天会话协议保持不变。 +MPA 智能体详情显示基本信息,有 Runtime 的 MPA 另向管理员显示协作 Agent。其他页面及 Profile 配置操作隐藏;本页不查询 Profile 状态或提交 Profile 操作。普通智能体页面与聊天会话协议保持不变。 ### MPA Feishu multi-bot accounts / MPA 飞书多机器人 @@ -1917,3 +1928,72 @@ BytePlus Studio MPA creation now retrieves the deployment-account APM AppKey thr BytePlus Studio MPA 创建现通过海外服务 Host 获取部署账号的 APM AppKey,注入 Runtime Envs 的 `OBSERVABILITY_OPENTELEMETRY_APMPLUS_API_KEY`。Key 不进入浏览器/任务/部署记录;获取失败时以安全诊断停止资源准备。重启 Studio 后使用新请求。国内/通用智能体创建和 CLI 配置不变。见 [APM Key 设计](../prd-spec/bugfixes/mpa-byteplus-apm/2026-10-14-server-app-key.zh.md)。 MPA 创建的镜像输入继续使用服务端 latest 默认值;部署层匿名只读匹配同公共仓库已有、digest 完全相同的非 latest tag,并持久化选择;重试只验证原选择。无需仓库写权限、手填版本或新增流水线;发布方须保留匹配版本标签且不得覆盖,无匹配项明确失败。详见 [Studio MPA 镜像快照契约](../specs/studio-mpa-creation/README.zh.md#studio-latest-内容快照2026-10-21)。 + +### MPA creation failure details / MPA 创建失败详情 + +Volcengine and BytePlus MPA creation failures show a safe reason and expandable/copyable stage, operation, error code and Request ID when available. No advice section or raw response bodies are shown. If the provider supplies no usable details, the page explicitly says so. Success, general agents and retry behavior are unchanged. Retry hides previous-attempt details but retains server diagnostic history. Restart Studio and refresh the browser after updating; old generic errors cannot be reconstructed. + +国内与 BytePlus 的 MPA 创建失败时,会显示安全原因,并支持展开/复制阶段、操作及可用的错误码、Request ID。不显示建议部分或原始响应体;平台未提供可用详情时明确说明。成功展示、通用智能体和重试行为不变。重试时隐藏上一轮详情,服务端仍保留诊断历史。更新后需重启 Studio 并刷新浏览器;以前未记录的具体错误无法恢复。 + +MPA failure details also include bounded exception types, HTTP status and normalized response type when available. Credential loading/STS/client setup failures are labeled separately; raw exception text and response bodies remain excluded. Restart Studio for the additive task-diagnostic schema; old generic failures cannot be reconstructed. +MPA 失败详情还会展示可用的受限异常类型、HTTP 状态、归一化响应类型。凭据加载、STS、客户端初始化失败独立标识,不展示原始异常文本和响应体。重启 Studio 后启用增量诊断字段,历史泛化错误无法还原。 + + +### Editing MPA AGENTS.md / 编辑 MPA AGENTS.md + +Studio administrators can open an MPA conversation, expand its information rail, +and choose **AGENTS.md → Edit**. Save changes to the selected Runtime; an empty +saved document removes custom instructions. A running turn keeps its snapshot; +the next Main turn uses the new content. Worker files receive updates, but an +already reused Worker thread may retain old instructions; Worker hot reload is +a known deferred limitation. Model, Skill and MCP settings remain unchanged. Conflicts +retain the draft and require refreshing the current revision. Older Runtimes +show an upgrade message; viewing their existing metadata remains available. + +Studio 管理员进入 MPA 会话,在右侧信息栏的 **AGENTS.md → 编辑** 中修改并保存。 +配置保存到当前选中的 Runtime;清空并保存会撤销自定义指令。正在执行的轮次保持 +原配置,已有会话的下一轮 Main 使用新内容。Worker 文件会更新,但复用中的 +Worker 线程可能仍使用旧指令;Worker 热更新是已知暂缓项。不会修改模型、Skill 或 MCP 配置。并发冲突会保留草稿,刷新核对后再保存。旧 Runtime +显示升级提示,原有信息查看功能保留。 + +This updates the selected Runtime directly. It does not add ArkClaw's separate +debug/publish environments. Bot Profile overrides and MA remote-host are unchanged. +这里直接更新选中的 Runtime,不新增 ArkClaw 的调试/发布双环境;Bot Profile +覆盖规则和 MA remote-host 保持原有行为。 + +Contracts: [English](../specs/studio-agents-md/README.md) / [中文](../specs/studio-agents-md/README.zh.md). + +### A2A Center registration + +MPA administrators open **Collaborating Agents → A2A Center**, select a space, and choose **Register Agent**. Select an authorized existing Runtime and its public/private endpoint, or import an Agent Card as JSON/HTTPS URL. Registrations live in AgentKit; **Connect selected space** controls which space this MPA can use. No per-Agent MPA binding is required. Creation uses the Studio server cloud identity; space listing/dispatch uses Runtime identity, so both must access the same account/region/space. On an unknown create result, refresh and reconcile before registering again. The previous local manual-registration form is retired; existing local configuration is cleaned only when its upgraded Runtime starts. + + +### A2A space management + +Collaborating Agents separates space binding from space members. Use **New space** to create an AgentKit A2A space in the target Runtime region with a name, project (default `default`) and optional semantic search (off by default). Studio requires management authorization, target Runtime access and cloud creation permission. Creation selects the space; click **Connect selected space** separately to bind it. **Register Agent** registers in the space shown in the members section. After an unconfirmed create or cancellation during submission, refresh and check the space list before another attempt. Local tests use simulated cloud responses; live provider creation requires separate verification. See the [bilingual design](../prd-spec/features/mpa-collaborating-agents/2026-10-22-space-management.md). + + +A2A Center member rows provide **View** and **Deregister**. View opens a bounded details dialog; deregistration removes the shared registry record after confirmation and preserves its source Runtime. **Delete space** requires an empty space and disconnection from the current MPA; the server rechecks both before deletion and never cascades member removal. Other applications may still depend on the space, so deletion requires explicit confirmation. Runtime registrations rejected as duplicates now show a specific message instead of an unconfirmed-write warning. Network/unknown write outcomes still require read reconciliation before another attempt. See the [member action design](../prd-spec/features/mpa-collaborating-agents/2026-10-22-member-actions.md). + + +A2A registration now reads the chosen Runtime's configured public/private endpoints and displays their addresses; endpoint loading, read errors and unavailable addresses block Runtime registration until resolved. View includes the sanitized effective service address and AgentCard skills (name, description, tags and examples), with distinct empty/unavailable states. URL query/fragment data and credentials are excluded. Space deletion preflight uses the same server-owned Runtime Studio credential as normal collaborator reads; Runtime authentication failure is reported separately from cloud permission refusal. Restart the Studio backend and refresh the page after updating. See the [endpoint/skill design](../prd-spec/features/mpa-collaborating-agents/2026-10-22-endpoints-and-skills.md) and [authentication fix](../prd-spec/bugfixes/mpa-a2a-space-deletion/2026-10-22-runtime-auth.md). + + +A2A Runtime registration now has one required Runtime picker. The selected Runtime ID is carried automatically; there is no separate manual-ID input. Runtime-list loading, empty and error states remain distinct, with retry for empty/error and pagination for more choices. A selection removed by a list refresh invalidates its old endpoint and cannot submit. The existing registration API and imported Agent Card workflows remain unchanged. See the [single-picker fix](../prd-spec/bugfixes/mpa-a2a-runtime-picker/2026-10-22-single-runtime-picker.md). + +### A2A Agent aliases / A2A Agent 别名 + +In Collaborating Agents → A2A Center, use **Set alias** on a registered Agent. Aliases belong to the current MPA business database, scoped by MPA/region/space/Agent. Clearing a value removes the alias. This does not rename the Agent in AgentKit. Start a conversation message with `@代码助手 检查代码` or `@"Code Assistant" review code`. A unique alias or actual Agent Card name resolves to the registered Agent; ambiguous or missing names require correction. Upgrade both Studio and Runtime; the alias feature needs no shared management database or AGENTKIT_ACCOUNT_ID. Previously shared aliases are retained in their old table but are not automatically imported; set the Agent alias again in each MPA. This UI does not add mention autocomplete. + +在协作 Agent → A2A 中心,对已注册的 Agent 点击**设置别名**。别名属于当前 MPA 业务库,按 MPA/地域/空间/Agent 隔离;留空可清除,不修改 AgentKit 中的名称。对话以 `@代码助手 检查代码` 或 `@"Code Assistant" review code` 开头,唯一别名或实际 Agent Card 名称可定位注册成员;歧义或不存在时需修正。须同步升级 Studio 和 Runtime;别名无需共享管理库或 AGENTKIT_ACCOUNT_ID。旧共享别名保留原表但不自动导入,需在各 MPA 重新设置。此 UI 不提供提及自动补全。 + +### MPA image updates / MPA 镜像更新 + +MPA management navigation places **Image updates** second, between Basic and Collaborating Agents. Runtime and Worker each accept a manually entered full tagged image address or a version from their corresponding fixed domestic/overseas public repository. Entering an address clears the public selection; selecting a public version clears the manual address. Both paths show the complete target and require independent confirmation. Use **Load more versions** to see additional public tags. Manual addresses may use other registries and remain usable when catalog loading fails; Studio validates format without contacting those registries, while AgentKit checks image pull permissions and compatibility. Runtime updates request immediate release; Worker updates change only the bound, exclusively managed template. Existing sandbox sessions may keep their old image: create a new session to use the updated template. A submitted request is not proof of application health. Refresh status to reconcile; an unknown outcome blocks duplicate submission. Restart Studio with its original cloud credential environment and refresh the browser to activate the new backend and assets. See the [image management contract](../specs/mpa-image-update/README.md). + +MPA 管理页将 **镜像更新** 放在基本信息和协作 Agent 之间。Runtime 与 Worker 均支持手填完整带标签镜像地址,或从对应国内/海外固定公共仓库选择版本。输入地址清空公共版本,选择公共版本清空手填内容;两种方式均展示完整目标并独立确认更新。可通过 **加载更多版本** 查看其他公共标签。手填可使用其他仓库,不受目录加载失败影响;Studio 仅校验格式而不请求该仓库,拉取权限和兼容性由 AgentKit 检查。Runtime 请求立即发布;Worker 只更新绑定且可证明独占归属的托管模板。已有沙箱会话可能保留原镜像,请新建会话使用新模板。提交成功不代表应用健康,需刷新状态核对;结果未确认时禁止重复提交。更新后须使用原云凭据环境重启 Studio 并刷新浏览器,启用新后端和资源。参见[镜像管理契约](../specs/mpa-image-update/README.zh.md)。 + + +Selecting **latest** from a public repository resolves it to an existing version before confirmation. The dialog shows the actual image and content digest; submission verifies the selected version again and does not follow a later change to latest. A missing/overwritten version or failed verification prevents an update. Read-only resolution can be retried. This applies only to MPA Runtime/Worker in Volcengine and BytePlus; manual addresses retain their existing behavior, including manually entered latest. + +从公共仓库选择 **latest** 时,确认前先解析为已有版本,确认框展示实际镜像及内容摘要。提交时再次校验该版本,不跟随之后变化的 latest;版本不存在、被覆盖或校验失败时不更新。可重试只读解析。此行为仅适用于国内和 BytePlus 的 MPA Runtime/Worker;手填地址(含手填 latest)维持原逻辑。 diff --git a/frontend/package.json b/frontend/package.json index eeeb17b0a..cea940b22 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,12 +3,12 @@ "private": true, "version": "0.1.0", "type": "module", - "description": "VeADK A2UI web UI — renders agent-driven UI over the ADK API server.", + "description": "VeADK A2UI web UI \u2014 renders agent-driven UI over the ADK API server.", "scripts": { "dev": "vite", "build": "node scripts/build.mjs", "test": "node --test tests/*.test.mjs && npm run test:mpa-agent-info", - "test:mpa-agent-info": "vitest run tests/mpaAgentInfoRail.test.tsx tests/skillDocumentEditor.test.tsx tests/mpaCreation.test.tsx tests/mpaBasicDetails.test.tsx tests/deploymentCategory.test.tsx tests/agentCategoryList.test.tsx tests/customCreateCategory.test.tsx tests/mpaDeletion.test.tsx", + "test:mpa-agent-info": "vitest run tests/mpaImages.test.tsx tests/a2aAlias.test.tsx tests/agentsMdEditor.test.tsx tests/collaboratingAgents.test.tsx tests/mpaAgentInfoRail.test.tsx tests/skillDocumentEditor.test.tsx tests/mpaCreation.test.tsx tests/mpaCreationFailure.test.tsx tests/mpaBasicDetails.test.tsx tests/deploymentCategory.test.tsx tests/agentCategoryList.test.tsx tests/customCreateCategory.test.tsx tests/mpaDeletion.test.tsx", "check:i18n": "node scripts/check-i18n.mjs", "test:sandbox-download-coverage": "vitest run --coverage -c vitest.sandbox-download.config.ts", "test:mpa-cron-coverage": "vitest run --coverage -c vitest.mpa-cron.config.ts", diff --git a/frontend/server/a2a_center.py b/frontend/server/a2a_center.py new file mode 100644 index 000000000..496338075 --- /dev/null +++ b/frontend/server/a2a_center.py @@ -0,0 +1,548 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Register existing services in AgentKit A2A Center using Studio cloud identity.""" + +import asyncio +import json +import logging +import re +from collections.abc import Callable +from urllib.parse import urlsplit, urlunsplit + +from fastapi import FastAPI, HTTPException, Request + +logger = logging.getLogger(__name__) + +MAX_BODY = 128 * 1024 + + +def _identifier(value: object, prefix: str) -> str: + if not isinstance(value, str) or not re.fullmatch( + prefix + r"-[A-Za-z0-9_-]{1,100}", value + ): + raise ValueError("Invalid identifier") + return value + + +def _card_url(value: object) -> str: + if ( + not isinstance(value, str) + or len(value) > 4096 + or any(c.isspace() for c in value) + ): + raise ValueError("Invalid URL") + parsed = urlsplit(value) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.fragment + ): + raise ValueError("Invalid URL") + # Accessing port also validates malformed or out-of-range ports. + _ = parsed.port + return value + + +def registration_payload(body: object) -> tuple[str, dict]: + """Parse the public source union, rejecting irrelevant or unrecognized fields.""" + if not isinstance(body, dict): + raise ValueError("Invalid body") + target = _identifier(body.get("targetRuntimeId"), "r") + project = body.get("projectName", "default") + if ( + not isinstance(project, str) + or not project.strip() + or len(project) > 128 + or any(ord(c) < 32 for c in project) + ): + raise ValueError("Invalid project") + payload = { + "A2aSpaceId": _identifier(body.get("spaceId"), "as"), + "Source": body.get("source"), + "SetDefaultVersion": True, + "ProjectName": project, + } + common = {"targetRuntimeId", "spaceId", "source", "projectName"} + if body.get("source") == "Runtime": + allowed = common | {"runtimeId", "networkType"} + if body.get("networkType") not in {"public", "private"}: + raise ValueError("Invalid network type") + payload["RuntimeConfig"] = { + "RuntimeId": _identifier(body.get("runtimeId"), "r"), + "NetworkType": body["networkType"], + } + elif body.get("source") == "Standard": + method = body.get("importType") + if method == "url": + allowed = common | {"importType", "cardUrl"} + payload["StandardConfig"] = { + "ImportType": "url", + "AgentCardUrl": _card_url(body.get("cardUrl")), + } + elif method == "json": + allowed = common | {"importType", "agentCard"} + card = body.get("agentCard") + if not isinstance(card, dict) or any( + not isinstance(card.get(key), str) or not card[key].strip() + for key in ("name", "url", "version") + ): + raise ValueError("Invalid Card") + _card_url(card["url"]) + payload["AgentCard"] = json.dumps(card, ensure_ascii=False, allow_nan=False) + payload["StandardConfig"] = {"ImportType": "json"} + else: + raise ValueError("Invalid import type") + else: + raise ValueError("Invalid source") + if set(body) - allowed: + raise ValueError("Unexpected fields") + return target, payload + + +class A2ACenterCloudError(RuntimeError): + """A safe provider error with no raw response or credential-bearing message.""" + + def __init__(self, status: int, reason: str): + self.status = status + self.reason = reason + super().__init__(reason) + + +def cloud_error(status: int, code: str = "", message: str = "") -> A2ACenterCloudError: + if message.strip().lower().startswith("runtimeid already exists"): + return A2ACenterCloudError(409, "runtime_already_registered") + if status >= 500: + return A2ACenterCloudError(502, "outcome_unknown") + if status in {401, 403} or code.startswith("AccessDenied"): + return A2ACenterCloudError(403, "forbidden") + if status == 404 or code.startswith("ResourceNotFound"): + return A2ACenterCloudError(404, "not_found") + if status in {400, 422} or code.startswith("InvalidParameter"): + return A2ACenterCloudError(422, "invalid_parameter") + if status == 409 or code.startswith( + ("AlreadyExists", "ResourceAlreadyExists", "ResourceInUse") + ): + return A2ACenterCloudError(409, "conflict") + if status == 429: + return A2ACenterCloudError(429, "rate_limited") + return A2ACenterCloudError(502, "outcome_unknown") + + +def _result(data: object) -> dict: + if not isinstance(data, dict) or not isinstance(data.get("Result"), dict): + raise HTTPException(502, {"reason": "invalid_response"}) + return data["Result"] + + +def _member(result: dict, agent_id: str, space_id: str) -> dict: + if result.get("Id") != agent_id or result.get("A2aSpaceId") != space_id: + raise HTTPException(502, {"reason": "invalid_response"}) + if result.get("Source") not in {"Runtime", "Standard"}: + raise HTTPException(502, {"reason": "invalid_response"}) + detail = {"id": agent_id} + for source, dest in [ + ("Name", "name"), + ("Status", "status"), + ("Source", "source"), + ("DefaultVersion", "defaultVersion"), + ("ProjectName", "projectName"), + ]: + value = result.get(source, "") + if not isinstance(value, str) or len(value) > 512: + raise HTTPException(502, {"reason": "invalid_response"}) + detail[dest] = value + for key, fields in [ + ("RuntimeConfig", {"RuntimeId": "runtimeId", "NetworkType": "networkType"}), + ("StandardConfig", {"ImportType": "importType"}), + ]: + config = result.get(key) or {} + if not isinstance(config, dict): + raise HTTPException(502, {"reason": "invalid_response"}) + for source, dest in fields.items(): + value = config.get(source, "") + if not isinstance(value, str) or len(value) > 512: + raise HTTPException(502, {"reason": "invalid_response"}) + detail[dest] = value + return detail + + +def _display_url(value: object) -> str | None: + """Project an address for display without URL credentials or query secrets.""" + if not isinstance(value, str) or not value or len(value) > 4096: + return None + if any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in value): + return None + try: + parsed = urlsplit(value) + if ( + parsed.scheme not in {"https", "http"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or "\\" in parsed.netloc + ): + return None + _ = parsed.port + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + except ValueError: + return None + + +def _runtime_endpoints(runtime: object) -> list[dict[str, str]]: + endpoints: dict[str, dict[str, str]] = {} + for config in getattr(runtime, "network_configurations", None) or []: + kind = getattr(config, "network_type", "") + address = _display_url(getattr(config, "endpoint", None)) + if kind in {"public", "private"} and address and kind not in endpoints: + endpoints[kind] = {"networkType": kind, "url": address} + return list(endpoints.values()) + + +def _card_metadata(raw: object) -> dict: + """Keep optional registry metadata bounded and separate from write checks.""" + detail: dict = {"serviceAddress": None, "skills": None, "skillsTruncated": False} + try: + if isinstance(raw, str): + if len(raw.encode()) > MAX_BODY: + return detail + card = json.loads(raw) + elif isinstance(raw, dict): + if len(json.dumps(raw, allow_nan=False).encode()) > MAX_BODY: + return detail + card = raw + else: + return detail + if not isinstance(card, dict): + return detail + detail["serviceAddress"] = _display_url(card.get("url")) + skills = card.get("skills") + if not isinstance(skills, list): + return detail + projected = [] + for skill in skills[:100]: + if not isinstance(skill, dict): + return detail + item = {} + for key, limit in [("id", 256), ("name", 512), ("description", 4096)]: + value = skill.get(key, "") + if not isinstance(value, str) or len(value) > limit: + return detail + item[key] = value + for key, limit in [("tags", 128), ("examples", 1024)]: + values = skill.get(key, []) + if ( + not isinstance(values, list) + or len(values) > 20 + or any(not isinstance(v, str) or len(v) > limit for v in values) + ): + return detail + item[key] = values + projected.append(item) + detail["skills"] = projected + detail["skillsTruncated"] = len(skills) > 100 + except (ValueError, TypeError, RecursionError): + pass # Optional metadata is explicitly unavailable, never a fabricated empty list. + return detail + + +def mount_routes( + app: FastAPI, + *, + admin: Callable, + authorize: Callable, + region_for: Callable, + cloud: Callable, + runtime_request: Callable | None = None, +) -> None: + async def invoke(**kwargs): + try: + return await cloud(**kwargs) + except A2ACenterCloudError as error: + logger.info( + "A2A action refused action=%s status=%s reason=%s", + kwargs.get("action"), + error.status, + error.reason, + ) + raise HTTPException( + error.status, {"code": "a2a_request_failed", "reason": error.reason} + ) from None + + async def target(request: Request, runtime_id: str, region: str): + admin(request) + try: + _identifier(runtime_id, "r") + except ValueError: + raise HTTPException(422, "invalid_a2a_target") from None + return await asyncio.to_thread(authorize, request, runtime_id, region) + + async def agent_result( + request: Request, agent_id: str, runtime_id: str, space_id: str, region: str + ): + await target(request, runtime_id, region) + try: + _identifier(agent_id, "a") + _identifier(space_id, "as") + except ValueError: + raise HTTPException(422, "invalid_a2a_target") from None + try: + result = _result( + await invoke( + region=region, + action="GetA2aAgent", + payload={"Id": agent_id, "SpaceId": space_id}, + ) + ) + _member(result, agent_id, space_id) + return result + except HTTPException as error: + if error.status_code >= 500: + raise HTTPException(502, {"reason": "read_failed"}) from None + raise + except Exception: + raise HTTPException(502, {"reason": "read_failed"}) from None + + @app.get("/web/a2a-runtime-endpoints/{source_runtime_id}") + async def runtime_endpoints( + request: Request, source_runtime_id: str, targetRuntimeId: str, region: str = "" + ): + region = region_for(region) + await target(request, targetRuntimeId, region) + try: + _identifier(source_runtime_id, "r") + except ValueError: + raise HTTPException(422, "invalid_a2a_target") from None + runtime = await asyncio.to_thread(authorize, request, source_runtime_id, region) + return { + "runtimeId": source_runtime_id, + "endpoints": _runtime_endpoints(runtime), + } + + @app.get("/web/a2a-agents/{agent_id}") + async def detail( + request: Request, + agent_id: str, + targetRuntimeId: str, + spaceId: str, + region: str = "", + ): + region = region_for(region) + result = await agent_result(request, agent_id, targetRuntimeId, spaceId, region) + return { + **_member(result, agent_id, spaceId), + **_card_metadata(result.get("AgentCard")), + } + + @app.delete("/web/a2a-agents/{agent_id}") + async def deregister( + request: Request, + agent_id: str, + targetRuntimeId: str, + spaceId: str, + region: str = "", + ): + region = region_for(region) + await agent_result(request, agent_id, targetRuntimeId, spaceId, region) + try: + _result( + await invoke( + region=region, action="DeleteA2aAgent", payload={"Id": agent_id} + ) + ) + except HTTPException: + raise + except Exception: + raise HTTPException(502, {"reason": "outcome_unknown"}) from None + return {"deleted": True} + + @app.delete("/web/a2a-spaces/{space_id}") + async def delete_space( + request: Request, space_id: str, targetRuntimeId: str, region: str = "" + ): + region = region_for(region) + runtime = await target(request, targetRuntimeId, region) + try: + _identifier(space_id, "as") + except ValueError: + raise HTTPException(422, "invalid_a2a_target") from None + if runtime_request is None: + raise HTTPException(503, {"reason": "read_failed"}) + try: + try: + binding = await runtime_request( + request=request, + runtime=runtime, + runtime_id=targetRuntimeId, + region=region, + method="GET", + path="/api/v1/studio/collaborators/registry/binding", + ) + except HTTPException as error: + if error.status_code in {401, 403}: + raise HTTPException( + error.status_code, {"reason": "runtime_auth_failed"} + ) from None + raise + if ( + not isinstance(binding, dict) + or "space_id" not in binding + or not ( + binding["space_id"] is None or isinstance(binding["space_id"], str) + ) + ): + raise HTTPException(502, {"reason": "invalid_response"}) + if binding["space_id"] == space_id: + raise HTTPException(409, {"reason": "space_bound"}) + space = _result( + await invoke( + region=region, action="GetA2aSpace", payload={"Id": space_id} + ) + ) + if space.get("Id") != space_id: + raise HTTPException(502, {"reason": "invalid_response"}) + members = _result( + await invoke( + region=region, + action="ListA2aAgents", + payload={"SpaceId": space_id, "PageNumber": 1, "PageSize": 1}, + ) + ) + if ( + not isinstance(members.get("Items"), list) + or type(members.get("TotalCount")) is not int + or members["TotalCount"] < 0 + ): + raise HTTPException(502, {"reason": "invalid_response"}) + if members["Items"] or members["TotalCount"]: + raise HTTPException(409, {"reason": "space_not_empty"}) + except HTTPException as error: + if error.status_code >= 500: + raise HTTPException(502, {"reason": "read_failed"}) from None + raise + except Exception: + raise HTTPException(502, {"reason": "read_failed"}) from None + try: + _result( + await invoke( + region=region, action="DeleteA2aSpace", payload={"Id": space_id} + ) + ) + except HTTPException: + raise + except Exception: + raise HTTPException(502, {"reason": "outcome_unknown"}) from None + return {"deleted": True} + + @app.post("/web/a2a-agents", status_code=201) + async def create(request: Request, region: str = ""): + admin(request) + region = region_for(region) + raw = bytearray() + async for chunk in request.stream(): + if len(raw) + len(chunk) > MAX_BODY: + raise HTTPException(413, "a2a_registration_too_large") + raw.extend(chunk) + try: + target, payload = registration_payload(json.loads(raw)) + except (ValueError, TypeError, RecursionError): + # No Pydantic/input or provider errors containing Card credentials. + raise HTTPException(422, "invalid_a2a_registration") from None + await asyncio.to_thread(authorize, request, target, region) + if payload["Source"] == "Runtime": + await asyncio.to_thread( + authorize, request, payload["RuntimeConfig"]["RuntimeId"], region + ) + try: + result = await invoke( + region=region, action="CreateA2aAgent", payload=payload + ) + identifier = _identifier(result["Result"]["Id"], "a") + except HTTPException: + raise + except Exception as error: + logger.warning( + "A2A registration outcome unknown region=%s space_id=%s error_type=%s", + region, + payload["A2aSpaceId"], + type(error).__name__, + ) + # An HTTP/network failure may have occurred after acceptance. Never retry here. + raise HTTPException( + 502, "a2a_registration_outcome_unknown_refresh_center" + ) from None + return {"id": identifier} + + @app.post("/web/a2a-spaces", status_code=201) + async def create_space(request: Request, region: str = ""): + admin(request) + region = region_for(region) + raw = bytearray() + async for chunk in request.stream(): + if len(raw) + len(chunk) > 4096: + raise HTTPException(413, "a2a_space_creation_too_large") + raw.extend(chunk) + try: + body = json.loads(raw) + if not isinstance(body, dict) or set(body) - { + "targetRuntimeId", + "name", + "projectName", + "intentEnabled", + }: + raise ValueError("Invalid body") + target = _identifier(body.get("targetRuntimeId"), "r") + name = _space_text(body.get("name")) + project = _space_text(body.get("projectName", "default")) + intent = body.get("intentEnabled", False) + if not isinstance(intent, bool): + raise ValueError("Invalid intent flag") + except (ValueError, TypeError, RecursionError): + raise HTTPException(422, "invalid_a2a_space_creation") from None + await asyncio.to_thread(authorize, request, target, region) + try: + result = await invoke( + region=region, + action="CreateA2aSpace", + payload={ + "Name": name, + "ProjectName": project, + "IntentEnabled": intent, + }, + ) + identifier = _identifier(result["Result"]["Id"], "as") + except HTTPException: + raise + except Exception as error: + logger.warning( + "A2A space creation outcome unknown region=%s error_type=%s", + region, + type(error).__name__, + ) + raise HTTPException( + 502, "a2a_space_creation_outcome_unknown_refresh_center" + ) from None + return {"id": identifier} + + +def _space_text(value: object) -> str: + """Apply Studio's bounded text policy without exposing input in errors.""" + if not isinstance(value, str) or any(ord(c) < 32 or ord(c) == 127 for c in value): + raise ValueError("Invalid space text") + value = value.strip() + if not value or len(value) > 128: + raise ValueError("Invalid space text") + return value diff --git a/frontend/server/mpa_images.py b/frontend/server/mpa_images.py new file mode 100644 index 000000000..8038e501c --- /dev/null +++ b/frontend/server/mpa_images.py @@ -0,0 +1,635 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Independent image management for exclusively owned MPA resources.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import re +from collections.abc import Callable +from types import SimpleNamespace +from typing import NoReturn + +import httpx +from fastapi import FastAPI, HTTPException, Query, Request + +from veadk.integrations.mpa.managed.database import agent_suffix +from veadk.integrations.mpa.managed.diagnostics import classify_error +from veadk.integrations.mpa.managed.runtime import env_map + +REPOSITORIES = { + "volcengine": "agentkit-platform-2112682748-cn-beijing.cr.volces.com/mpa", + "byteplus": "agentkit-platform-3001037806-ap-southeast-1.cr.bytepluses.com/mpa", +} +COMPONENTS = {"runtime": "mpa_agent", "worker": "mpa_codex_worker"} +READY = {"ready", "running", "available"} +ACTIVE = {"submitting", "submitted", "uncertain"} +MAX_RESPONSE = 1024 * 1024 + + +def fail(status: int, reason: str) -> NoReturn: + raise HTTPException(status, {"reason": reason}) + + +def repository(provider: str, component: str) -> str: + if provider not in REPOSITORIES or component not in COMPONENTS: + fail(422, "invalid_request") + return REPOSITORIES[provider] + "/" + COMPONENTS[component] + + +def valid_tag(value: object) -> bool: + return ( + isinstance(value, str) + and re.fullmatch(r"[\w][\w.-]{0,127}", value, re.ASCII) is not None + ) + + +_IMAGE_HOST = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" +_IMAGE_PART = r"[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*" +_IMAGE_REFERENCE = re.compile( + rf"(?P{_IMAGE_HOST}(?:\.{_IMAGE_HOST})*)(?::(?P[0-9]{{1,5}}))?/" + rf"{_IMAGE_PART}(?:/{_IMAGE_PART})*:[\w][\w.-]{{0,127}}", + re.ASCII, +) + + +def valid_image(value: object) -> bool: + """Validate a tagged image reference without contacting its registry.""" + if not isinstance(value, str) or len(value) > 2048: + return False + match = _IMAGE_REFERENCE.fullmatch(value) + if match is None: + return False + port = match.group("port") + host = match.group("host") + return (port is None or 1 <= int(port) <= 65535) and ( + "." in host or port is not None or host == "localhost" + ) + + +def valid_selection(provider: str, component: str, value: object) -> bool: + if not isinstance(value, dict) or set(value) != {"image", "digest"}: + return False + image, digest = value["image"], value["digest"] + return ( + valid_image(image) + and image.startswith(repository(provider, component) + ":") + and image.rsplit(":", 1)[1] != "latest" + and isinstance(digest, str) + and re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is not None + ) + + +async def resolve_latest(provider: str, component: str, *, selection=None): + """Reuse creation's anonymous resolver without its persisted creation state.""" + from veadk.integrations.mpa.managed.image_snapshot import snapshot_image + + try: + result = await asyncio.to_thread( + snapshot_image, + repository(provider, component) + ":latest", + SimpleNamespace(provider=provider), + selection=selection, + ) + except Exception: + fail(502, "latest_resolution_failed") + if not valid_selection(provider, component, result) or ( + selection is not None and result != selection + ): + fail(502, "latest_resolution_failed") + return result + + +def check_revision(detail, revision): + if detail["revision"] != revision: + fail(409, "stale") + if not detail["canUpdate"]: + fail(409, detail["reason"]) + + +class PublicCatalog: + def __init__(self, *, transport=None): + self.transport = transport + + async def _read(self, repo: str, suffix: str, *, params=None, head=False): + host, path = repo.split("/", 1) + url = f"https://{host}/v2/{path}/{suffix}" + async with httpx.AsyncClient( + timeout=12, follow_redirects=False, transport=self.transport + ) as client: + + async def send(target, *, headers=None, query=None, method="GET"): + async with client.stream( + method, target, params=query, headers=headers + ) as response: + content = bytearray() + async for chunk in response.aiter_bytes(): + content.extend(chunk) + if len(content) > MAX_RESPONSE: + fail(502, "catalog_failed") + return response.status_code, response.headers, bytes(content) + + headers = { + "Accept": "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json" + } + method = "HEAD" if head else "GET" + status, response_headers, body = await send( + url, headers=headers, query=params, method=method + ) + if status == 401: + # Never follow the remote challenge realm or a pagination Link URL. + token_status, _, token_body = await send( + f"https://{host}/service/token", + query={ + "service": "harbor-registry", + "scope": f"repository:{path}:pull", + }, + ) + if token_status != 200: + fail(502, "catalog_failed") + token_data = json.loads(token_body) + token = token_data.get("token") or token_data.get("access_token") + if ( + not isinstance(token, str) + or not token + or len(token) > 16384 + or any(c.isspace() for c in token) + ): + fail(502, "catalog_failed") + headers["Authorization"] = "Bearer " + token + status, response_headers, body = await send( + url, headers=headers, query=params, method=method + ) + if status != 200: + fail(502, "catalog_failed") + return response_headers, body + + async def page(self, provider: str, component: str, cursor: str): + repo = repository(provider, component) + if cursor and not valid_tag(cursor): + fail(422, "invalid_request") + try: + headers, body = await asyncio.wait_for( + self._read( + repo, + "tags/list", + params={"n": 100, **({"last": cursor} if cursor else {})}, + ), + 25, + ) + data = json.loads(body) + tags = data.get("tags") if isinstance(data, dict) else None + if tags is None and isinstance(data, dict) and "tags" in data: + tags = [] + if ( + not isinstance(tags, list) + or len(tags) > 100 + or any(not valid_tag(tag) for tag in tags) + or len(set(tags)) != len(tags) + or cursor in tags + ): + fail(502, "catalog_failed") + return { + "repository": repo, + "tags": tags, + "nextCursor": tags[-1] + if tags and (headers.get("link") or len(tags) == 100) + else "", + } + except HTTPException: + raise + except ( + httpx.HTTPError, + ValueError, + TypeError, + AttributeError, + TimeoutError, + asyncio.TimeoutError, + ): + fail(502, "catalog_failed") + + async def verify(self, provider: str, component: str, tag: str): + repo = repository(provider, component) + if not valid_tag(tag): + fail(422, "invalid_request") + try: + await asyncio.wait_for(self._read(repo, "manifests/" + tag, head=True), 25) + except HTTPException: + raise + except ( + httpx.HTTPError, + ValueError, + TypeError, + AttributeError, + TimeoutError, + asyncio.TimeoutError, + ): + fail(502, "catalog_failed") + + +class ImageManager: + """Caller holds the existing account/region registry lock for this lifetime.""" + + def __init__( + self, + *, + entry, + cloud, + worker, + runtime, + account, + region, + agent_id, + provider, + catalog, + ): + self.entry, self.cloud, self.worker, self.runtime = ( + entry, + cloud, + worker, + runtime, + ) + self.account, self.region, self.agent_id = account, region, agent_id + self.provider, self.catalog = provider, catalog + self.write_started = False + + async def _snapshot(self, component): + repo = repository(self.provider, component) + record = await self.entry.read() if self.entry is not None else {} + runtime = await self.cloud.get(self.runtime["RuntimeId"]) + values = env_map(runtime) + binding = runtime.get("ToolId") or values.get("AGENTKIT_TOOL_ID", "") + reason = "" + if ( + runtime.get("RuntimeId") != self.runtime["RuntimeId"] + or values.get("MPA_AGENT_ID") != self.agent_id + or not record + or record.get("runtime_id") != runtime.get("RuntimeId") + ): + reason = "ownership" + elif record.get("pending") or record.get("state") in {"deleting", "deleted"}: + reason = "busy" + if values.get("SHARED_APIG_DATABASE_URL") != env_map(self.runtime).get( + "SHARED_APIG_DATABASE_URL" + ): + reason = "ownership" + if values.get("AGENTKIT_TOOL_REGION") not in (None, "", self.region): + reason = "ownership" + resource = runtime + if component == "worker": + if not binding: + reason = "missing_worker" + resource = {} + elif not reason: + if ( + record.get("worker_id") != binding + or record.get("worker_managed") is not True + or ( + runtime.get("ToolId") + and values.get("AGENTKIT_TOOL_ID") + and runtime["ToolId"] != values["AGENTKIT_TOOL_ID"] + ) + ): + reason = "ownership" + resource = await self.worker.get(binding) if not reason else {} + tags = { + t.get("Key"): t.get("Value") for t in resource.get("Tags") or [] + } + if ( + resource.get("ToolId") != binding + or tags.get("managed_by") != "mpa-deployment" + or tags.get("mpa_agent_key") + != agent_suffix(self.account, self.region, self.agent_id) + or resource.get("ProjectName") + != (runtime.get("ProjectName") or "default") + or env_map(resource).get("MPA_AGENT_ID") + not in (None, "", self.agent_id) + ): + reason = "ownership" + if not reason: + assert self.entry is not None + others = await self.entry.other_records() + runtimes = await self.cloud.list_runtimes() + if any( + r.get("worker_id") == binding and r.get("state") != "deleted" + for r in others + ) or any( + r.get("RuntimeId") != runtime["RuntimeId"] + and binding + in {r.get("ToolId"), env_map(r).get("AGENTKIT_TOOL_ID")} + for r in runtimes + ): + reason = "ownership" + else: + resource = {} + image_key = "ArtifactUrl" if component == "runtime" else "ImageUrl" + current = str(resource.get(image_key) or "") + if current and ( + len(current) > 2048 + or "//" in current + or not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9./:_-]*(?:@sha256:[0-9a-f]{64})?", current + ) + ): + # Cloud metadata must not turn signed URLs or credentials into UI text. + current, reason = "", "ownership" + status = str(resource.get("Status") or "") + resource_id = runtime["RuntimeId"] if component == "runtime" else binding + operation = (record.get("image_updates") or {}).get(component) + if operation and operation.get("phase") in ACTIVE: + if ( + not reason + and current == operation.get("target") + and status.lower() in READY + ): + operation = {**operation, "phase": "configured"} + record["image_updates"][component] = operation + assert self.entry is not None + await self.entry.save(record) + else: + reason = reason or "busy" + if not reason and (not current or status.lower() not in READY): + reason = "busy" + revision = hashlib.sha256( + json.dumps( + [ + runtime.get("RuntimeId"), + binding, + self.agent_id, + resource_id, + runtime.get("ProjectName"), + runtime.get("Tags"), + resource.get("Tags"), + current, + status, + ], + sort_keys=True, + ).encode() + ).hexdigest() + return record, { + "component": component, + "resourceId": resource_id, + "repository": repo, + "currentImage": current, + "status": status, + "revision": revision, + "canUpdate": not reason, + "reason": reason, + "operation": operation, + } + + async def detail(self, component): + return (await self._snapshot(component))[1] + + async def update(self, component, tag, revision, *, image=None, selection=None): + if ( + (tag is None) == (image is None) + or not isinstance(revision, str) + or len(revision) > 128 + ): + fail(422, "invalid_request") + if image is not None and not valid_image(image): + fail(422, "invalid_image") + if tag is not None and not valid_tag(tag): + fail(422, "invalid_request") + public_repo = repository(self.provider, component) + latest = image is None and tag == "latest" + if selection is not None and ( + not latest or not valid_selection(self.provider, component, selection) + ): + fail(422, "invalid_request") + if image is not None: + target = image + else: + assert isinstance(tag, str) + target = public_repo + ":" + tag + artifact_type = ( + "public_image" if target.startswith(public_repo + ":") else "image" + ) + record, detail = await self._snapshot(component) + check_revision(detail, revision) + if latest: + selection = await resolve_latest( + self.provider, component, selection=selection + ) + target = selection["image"] + if target == detail["currentImage"]: + fail(409, "unchanged") + if image is None and not latest: + await self.catalog.verify(self.provider, component, tag) + # Recheck the resource after target validation and any catalog I/O. + record, fresh = await self._snapshot(component) + if fresh["revision"] != revision or not fresh["canUpdate"]: + fail(409, "stale") + operation = {"target": target, "phase": "submitting"} + if latest: + assert selection is not None + operation.update( + requestedImage=public_repo + ":latest", digest=selection["digest"] + ) + record.setdefault("image_updates", {})[component] = operation + await self.entry.save(record) + try: + self.write_started = True + if component == "runtime": + await self.cloud.update( + { + "RuntimeId": detail["resourceId"], + "ArtifactType": artifact_type, + "ArtifactUrl": target, + "ReleaseEnable": True, + } + ) + else: + await self.worker.call( + "update_tool", + "UpdateToolRequest", + ToolId=detail["resourceId"], + ImageUrl=target, + ) + except Exception as error: + category = classify_error(error) + rejected = category in { + "permission", + "invalid_request", + "not_found", + "throttled", + } + operation["phase"] = "rejected" if rejected else "uncertain" + await self.entry.save(record) + fail( + 403 if category == "permission" else 409 if rejected else 502, + "forbidden" + if category == "permission" + else "rejected" + if rejected + else "outcome_unknown", + ) + operation["phase"] = "submitted" + await self.entry.save(record) + return {**detail, "canUpdate": False, "reason": "busy", "operation": operation} + + +def mount_routes( + app: FastAPI, + *, + admin: Callable, + authorize: Callable, + region_for: Callable, + provider: str, + scope: Callable, + catalog=None, +): + catalog = catalog or PublicCatalog() + running: set[asyncio.Task] = set() + + async def access(request, runtime_id, component, region): + admin(request) + repository(provider, component) + if not re.fullmatch(r"r-[A-Za-z0-9_-]{1,100}", runtime_id): + fail(422, "invalid_request") + region = region_for(region) + if not re.fullmatch(r"(?:cn-[a-z]+|ap-[a-z]+-[0-9]+)", region) or ( + provider == "volcengine" + ) != region.startswith("cn-"): + fail(422, "invalid_request") + runtime = await asyncio.to_thread(authorize, request, runtime_id, region) + return runtime, region + + async def managed(request, runtime_id, component, region, body=None): + runtime, region = await access(request, runtime_id, component, region) + manager = None + try: + async with scope(runtime, region, catalog) as manager: + if body is None: + return await manager.detail(component) + return await manager.update( + component, + body.get("tag"), + body["revision"], + image=body.get("image"), + selection=body.get("selection"), + ) + except HTTPException: + raise + except Exception as error: + if classify_error(error) == "permission": + fail(403, "forbidden") + fail( + 502, + "outcome_unknown" + if manager is not None and manager.write_started + else "read_failed", + ) + + @app.get("/web/mpa-images/{runtime_id}/{component}/versions") + async def versions( + request: Request, + runtime_id: str, + component: str, + region: str = "", + cursor: str = Query(default="", max_length=128), + ): + await access(request, runtime_id, component, region) + return await catalog.page(provider, component, cursor) + + @app.get("/web/mpa-images/{runtime_id}/{component}/latest-preview") + async def latest_preview( + request: Request, + runtime_id: str, + component: str, + revision: str = Query(min_length=1, max_length=128), + region: str = "", + ): + async def preview(): + # Discovery can be slow. Do not hold a provisioning lock while reading tags. + check_revision( + await managed(request, runtime_id, component, region), revision + ) + selection = await resolve_latest(provider, component) + check_revision( + await managed(request, runtime_id, component, region), revision + ) + return { + "component": component, + "revision": revision, + "requestedImage": repository(provider, component) + ":latest", + **selection, + } + + try: + return await asyncio.wait_for(preview(), 180) + except asyncio.TimeoutError: + fail(504, "latest_resolution_failed") + + @app.get("/web/mpa-images/{runtime_id}/{component}") + async def detail( + request: Request, runtime_id: str, component: str, region: str = "" + ): + try: + return await asyncio.wait_for( + managed(request, runtime_id, component, region), 30 + ) + except asyncio.TimeoutError: + fail(504, "read_failed") + + @app.post("/web/mpa-images/{runtime_id}/{component}") + async def update( + request: Request, runtime_id: str, component: str, region: str = "" + ): + admin(request) + raw = await request.body() + if len(raw) > 4096: + fail(413, "invalid_request") + try: + body = json.loads(raw) + except ValueError: + fail(422, "invalid_request") + if ( + not isinstance(body, dict) + or set(body) + not in ( + {"tag", "revision"}, + {"image", "revision"}, + {"tag", "revision", "selection"}, + ) + or not isinstance(body["revision"], str) + or len(body["revision"]) > 128 + ): + fail(422, "invalid_request") + if "image" in body and not valid_image(body["image"]): + fail(422, "invalid_image") + if "tag" in body and not valid_tag(body["tag"]): + fail(422, "invalid_request") + if "selection" in body and ( + body.get("tag") != "latest" + or not valid_selection(provider, component, body["selection"]) + ): + fail(422, "invalid_request") + # Keep the lock until the synchronous SDK call finishes, even on disconnect. + task = asyncio.create_task( + managed(request, runtime_id, component, region, body) + ) + running.add(task) + + def done(completed): + running.discard(completed) + if not completed.cancelled(): + completed.exception() + + task.add_done_callback(done) + return await asyncio.shield(task) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5981e267c..90faf0bd2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7721,6 +7721,7 @@ export default function App() { loadingAgentInfo={capabilitiesLoading} canCreate={canCreateRuntimeAgents} canUpdate={canCreateRuntimeAgents || canManageAgents} + canManageCollaborators={access?.role === "admin" || access?.role === "super_admin"} canViewUsage={canViewAgentUsage} loadingAgents={agentLibraryLoading} agentsError={agentLibraryError} @@ -8628,6 +8629,9 @@ export default function App() { {!sandboxSession && agentInfoSelectionRef.current === appName && agentInfo?.agentCategory === "mpa" && ( { preparedAgentSelectionRef.current = null; diff --git a/frontend/src/adk/a2aSpace.ts b/frontend/src/adk/a2aSpace.ts new file mode 100644 index 000000000..0121ed1fa --- /dev/null +++ b/frontend/src/adk/a2aSpace.ts @@ -0,0 +1,364 @@ +import { CollaboratorApiError } from "./collaboratingAgents"; + +export type SpaceBinding = { + space_id: string | null; + space_name: string | null; + region: string; + revision: number; +}; +export type RegistryItem = { + Id: string; + Name: string; + Status?: string; + Description?: string; + ProjectName?: string; + Alias?: string | null; + AliasRevision?: number; + AliasAvailable?: boolean; +}; +export type RegistryPage = { + items: RegistryItem[]; + total: number; + page: number; + page_size: number; +}; +function object(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +async function json(response: Response): Promise { + if (!response.ok) { + let reason: string | undefined; + try { + const body: unknown = await response.json(); + if ( + object(body) && + typeof body.detail === "string" && + [ + "alias_conflict", + "revision_conflict", + "invalid_alias", + "alias_store_not_configured", + "alias_store_unavailable", + ].includes(body.detail) + ) + reason = body.detail; + if ( + object(body) && + object(body.detail) && + typeof body.detail.reason === "string" && + [ + "runtime_already_registered", + "forbidden", + "invalid_parameter", + "not_found", + "conflict", + "rate_limited", + "space_bound", + "space_not_empty", + "read_failed", + "runtime_auth_failed", + ].includes(body.detail.reason) + ) + reason = body.detail.reason; + } catch { + /* Non-JSON errors have no safe structured reason. */ + } + throw new CollaboratorApiError(response.status, reason); + } + return response.json(); +} +export async function spaceBinding(response: Response): Promise { + const data = await json(response); + if ( + !object(data) || + !(data.space_id === null || typeof data.space_id === "string") || + typeof data.region !== "string" || + !Number.isInteger(data.revision) || + typeof data.revision !== "number" || + data.revision < 0 || + !(data.space_name == null || typeof data.space_name === "string") + ) + throw new CollaboratorApiError(502); + return { + space_id: data.space_id, + space_name: data.space_name ?? null, + region: data.region, + revision: data.revision, + }; +} +export async function registryPage(response: Response): Promise { + const data = await json(response); + if ( + !object(data) || + !Array.isArray(data.items) || + typeof data.total !== "number" || + data.total < 0 || + typeof data.page !== "number" || + data.page < 1 || + typeof data.page_size !== "number" || + data.page_size < 1 + ) + throw new CollaboratorApiError(502); + const items = data.items.map((item: unknown): RegistryItem => { + if ( + !object(item) || + typeof item.Id !== "string" || + typeof item.Name !== "string" || + !(item.Status === undefined || typeof item.Status === "string") || + !( + item.Description === undefined || typeof item.Description === "string" + ) || + !(item.Alias == null || typeof item.Alias === "string") || + !( + item.AliasRevision === undefined || + (typeof item.AliasRevision === "number" && + Number.isInteger(item.AliasRevision) && + item.AliasRevision >= 0) + ) || + !( + item.AliasAvailable === undefined || + typeof item.AliasAvailable === "boolean" + ) || + !(item.ProjectName === undefined || typeof item.ProjectName === "string") + ) + throw new CollaboratorApiError(502); + return { + Id: item.Id, + Name: item.Name, + Status: item.Status, + Description: item.Description, + ProjectName: item.ProjectName, + Alias: item.Alias, + AliasRevision: item.AliasRevision, + AliasAvailable: item.AliasAvailable, + }; + }); + return { + items, + total: data.total, + page: data.page, + page_size: data.page_size, + }; +} + +export type CenterRegistration = { + targetRuntimeId: string; + spaceId: string; + projectName: string; +} & ( + | { source: "Runtime"; runtimeId: string; networkType: "public" | "private" } + | { source: "Standard"; importType: "url"; cardUrl: string } + | { + source: "Standard"; + importType: "json"; + agentCard: Record; + } +); +export async function createdAgent(response: Response): Promise { + const data = await json(response); + if ( + !object(data) || + typeof data.id !== "string" || + !/^a-[\w-]+$/.test(data.id) + ) + throw new CollaboratorApiError(502); + return data.id; +} +export function parseRegistrationCard(raw: string): Record { + const data: unknown = JSON.parse(raw); + if ( + !object(data) || + ["name", "url", "version"].some( + (key) => typeof data[key] !== "string" || !data[key], + ) + ) + throw new Error("invalid_card"); + return data; +} + +export type SpaceCreation = { + targetRuntimeId: string; + name: string; + projectName: string; + intentEnabled: boolean; +}; +export async function createdSpace(response: Response): Promise { + const data = await json(response); + if ( + !object(data) || + typeof data.id !== "string" || + !/^as-[\w-]+$/.test(data.id) + ) + throw new CollaboratorApiError(502); + return data.id; +} + +export type CenterAgentDetail = { + id: string; + name: string; + status: string; + source: string; + defaultVersion?: string; + projectName?: string; + runtimeId?: string; + networkType?: string; + importType?: string; + serviceAddress: string | null; + skills: CenterAgentSkill[] | null; + skillsTruncated: boolean; +}; +export type CenterAgentSkill = { + id: string; + name: string; + description: string; + tags: string[]; + examples: string[]; +}; +export type RuntimeEndpoint = { + networkType: "public" | "private"; + url: string; +}; +function displayUrl(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length > 4096 || + /[\s\u0000-\u001f\u007f]/.test(value) + ) + return false; + try { + const url = new URL(value); + return ( + ["http:", "https:"].includes(url.protocol) && + !url.username && + !url.password && + !url.search && + !url.hash + ); + } catch { + return false; + } +} +export async function runtimeEndpoints( + response: Response, + runtimeId: string, +): Promise { + const data = await json(response); + if ( + !object(data) || + data.runtimeId !== runtimeId || + !Array.isArray(data.endpoints) || + data.endpoints.length > 2 + ) + throw new CollaboratorApiError(502); + const endpoints: RuntimeEndpoint[] = []; + for (const endpoint of data.endpoints) { + if ( + !object(endpoint) || + (endpoint.networkType !== "public" && + endpoint.networkType !== "private") || + !displayUrl(endpoint.url) || + endpoints.some((item) => item.networkType === endpoint.networkType) + ) + throw new CollaboratorApiError(502); + endpoints.push({ networkType: endpoint.networkType, url: endpoint.url }); + } + return endpoints; +} +export async function centerAgentDetail( + response: Response, + id: string, +): Promise { + const data = await json(response); + if ( + !object(data) || + data.id !== id || + !["Runtime", "Standard"].includes(String(data.source)) || + ["name", "status", "source"].some((key) => typeof data[key] !== "string") + ) + throw new CollaboratorApiError(502); + const detail: CenterAgentDetail = { + id, + name: String(data.name), + status: String(data.status), + source: String(data.source), + serviceAddress: null, + skills: null, + skillsTruncated: false, + }; + for (const key of [ + "defaultVersion", + "projectName", + "runtimeId", + "networkType", + "importType", + ] as const) { + if (data[key] !== undefined && typeof data[key] !== "string") + throw new CollaboratorApiError(502); + if (typeof data[key] === "string") detail[key] = data[key]; + } + if (data.serviceAddress != null) { + if (!displayUrl(data.serviceAddress)) throw new CollaboratorApiError(502); + detail.serviceAddress = data.serviceAddress; + } + if (data.skills != null) { + if (!Array.isArray(data.skills) || data.skills.length > 100) + throw new CollaboratorApiError(502); + detail.skills = data.skills.map((skill: unknown) => { + if (!object(skill)) throw new CollaboratorApiError(502); + for (const [key, limit] of [ + ["id", 256], + ["name", 512], + ["description", 4096], + ] as const) { + if (typeof skill[key] !== "string" || skill[key].length > limit) + throw new CollaboratorApiError(502); + } + for (const [key, limit] of [ + ["tags", 128], + ["examples", 1024], + ] as const) { + const values = skill[key]; + if ( + !Array.isArray(values) || + values.length > 20 || + values.some( + (value) => typeof value !== "string" || value.length > limit, + ) + ) + throw new CollaboratorApiError(502); + } + return { + id: String(skill.id), + name: String(skill.name), + description: String(skill.description), + tags: skill.tags as string[], + examples: skill.examples as string[], + }; + }); + } + if (data.skillsTruncated !== undefined) { + if (typeof data.skillsTruncated !== "boolean") + throw new CollaboratorApiError(502); + detail.skillsTruncated = data.skillsTruncated; + } + return detail; +} +export async function deletedResource(response: Response): Promise { + const data = await json(response); + if (!object(data) || data.deleted !== true) + throw new CollaboratorApiError(502); +} + +export type AliasRecord = { alias: string | null; revision: number }; +export async function aliasRecord(response: Response): Promise { + const data = await json(response); + if ( + !object(data) || + !(data.alias === null || typeof data.alias === "string") || + typeof data.revision !== "number" || + !Number.isInteger(data.revision) || + data.revision < 1 + ) + throw new CollaboratorApiError(502); + return { alias: data.alias, revision: data.revision }; +} diff --git a/frontend/src/adk/agentsMd.ts b/frontend/src/adk/agentsMd.ts new file mode 100644 index 000000000..46db01bc3 --- /dev/null +++ b/frontend/src/adk/agentsMd.ts @@ -0,0 +1,41 @@ +import { requestAgentsMd } from "./client"; + +export interface AgentsMdTarget { + runtimeId: string; + region: string; +} +export interface AgentsMdDocument { + content: string; + revision: number; +} +export class AgentsMdError extends Error { + constructor(public readonly status: number) { + super(`AGENTS.md request failed: ${status}`); + } +} + +export async function requestAgentsMdDocument( + target: AgentsMdTarget, + signal: AbortSignal, + document?: AgentsMdDocument, +): Promise { + const response = await requestAgentsMd(target.runtimeId, target.region, { + signal, + ...(document ? { method: "POST", body: JSON.stringify(document) } : {}), + }); + if (!response.ok) throw new AgentsMdError(response.status); + const value: unknown = await response.json(); + if ( + typeof value !== "object" || + value === null || + !("content" in value) || + !("revision" in value) || + typeof value.content !== "string" || + typeof value.revision !== "number" || + !Number.isSafeInteger(value.revision) || + value.revision < 0 + ) { + throw new Error("Invalid AGENTS.md response"); + } + return { content: value.content, revision: value.revision }; +} diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 7c14cd647..ce161df9f 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -6627,3 +6627,48 @@ export function listMpaCronTasks(runtime: MpaRuntime, offset: number, query: str export function requestMpaTask(runtime: MpaRuntime, method: string, suffix: string, payload?: unknown, signal?: AbortSignal) { return manageMpaTask(apiFetch, runtime, method, suffix, payload, signal); } export function listMpaRuns(runtime: MpaRuntime, taskId: string, offset: number, signal?: AbortSignal) { return fetchMpaRuns(apiFetch, runtime, taskId, offset, signal); } + +// Registration management deliberately has no task-dispatch endpoint. +export function requestCollaborators(runtimeId: string, region: string, suffix = "", init: RequestInit = {}) { + return apiFetch(`/api/v1/studio/collaborators${suffix}`, { + ...init, headers: {"Content-Type": "application/json", ...init.headers}, + }, {runtimeId, region}); +} + +export function requestAgentsMd(runtimeId: string, region: string, init: RequestInit = {}) { + return apiFetch("/api/v1/studio/agents-md", { + ...init, headers: { "Content-Type": "application/json", ...init.headers }, + }, { runtimeId, region }); +} + +/** Register in AgentKit Center using Studio's cloud identity, not the Runtime proxy. */ +export function createA2aCenterAgent(region: string, payload: import('./a2aSpace').CenterRegistration, signal?: AbortSignal) { + return studioFetch(`/web/a2a-agents?${new URLSearchParams({region})}`, { + method: 'POST', signal, headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload), + }); +} + +/** Create a space through Studio's authorized cloud control plane. */ +export function createA2aSpace(region: string, payload: import('./a2aSpace').SpaceCreation, signal?: AbortSignal) { + return studioFetch(`/web/a2a-spaces?${new URLSearchParams({region})}`, { + method: 'POST', signal, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload), + }); +} + +export function requestA2aCenterAgent(targetRuntimeId: string, region: string, spaceId: string, agentId: string, init: RequestInit = {}) { + return studioFetch(`/web/a2a-agents/${encodeURIComponent(agentId)}?${new URLSearchParams({targetRuntimeId, region, spaceId})}`, init); +} +export function deleteA2aCenterSpace(targetRuntimeId: string, region: string, spaceId: string, signal?: AbortSignal) { + return studioFetch(`/web/a2a-spaces/${encodeURIComponent(spaceId)}?${new URLSearchParams({targetRuntimeId, region})}`, {method: "DELETE", signal}); +} + +export function requestA2aRuntimeEndpoints(targetRuntimeId: string, region: string, sourceRuntimeId: string, signal?: AbortSignal) { + return studioFetch(`/web/a2a-runtime-endpoints/${encodeURIComponent(sourceRuntimeId)}?${new URLSearchParams({targetRuntimeId, region})}`, {signal}); +} + +export function requestMpaImage(runtimeId: string, region: string, component: "runtime" | "worker", suffix = "", init: RequestInit = {}) { + const params = new URLSearchParams({ region }); + return apiFetch(`/web/mpa-images/${encodeURIComponent(runtimeId)}/${component}${suffix}${suffix.includes("?") ? "&" : "?"}${params}`, { + ...init, headers: { "Content-Type": "application/json", ...init.headers }, + }, {}, suffix.startsWith("/latest-preview") || init.method === "POST" ? 180_000 : 30_000); +} diff --git a/frontend/src/adk/collaboratingAgents.ts b/frontend/src/adk/collaboratingAgents.ts new file mode 100644 index 000000000..552a0a19d --- /dev/null +++ b/frontend/src/adk/collaboratingAgents.ts @@ -0,0 +1,8 @@ +export class CollaboratorApiError extends Error { + constructor( + public status: number, + public reason?: string, + ) { + super(`Collaborator request failed (${status})`); + } +} diff --git a/frontend/src/adk/mpaCreation.ts b/frontend/src/adk/mpaCreation.ts index 31feb60e6..414274781 100644 --- a/frontend/src/adk/mpaCreation.ts +++ b/frontend/src/adk/mpaCreation.ts @@ -36,6 +36,9 @@ export interface MpaCreationTask category: string; attempt: number; outcome: string; + exceptionType?: string; + httpStatus?: number; + responseType?: string; errorCode?: string; requestId?: string; }; diff --git a/frontend/src/adk/mpaImages.ts b/frontend/src/adk/mpaImages.ts new file mode 100644 index 000000000..0b108a9ca --- /dev/null +++ b/frontend/src/adk/mpaImages.ts @@ -0,0 +1,145 @@ +export type ImageComponent = "runtime" | "worker"; +export type ImageOperation = { + target: string; + phase: "submitting" | "submitted" | "uncertain" | "configured" | "rejected"; +}; +export type ImageDetail = { + component: ImageComponent; + resourceId: string; + repository: string; + currentImage: string; + status: string; + revision: string; + canUpdate: boolean; + reason: string; + operation: ImageOperation | null; +}; +export type ImageVersions = { + repository: string; + tags: string[]; + nextCursor: string; +}; +export type ImageSelection = { image: string; digest: string }; +export type LatestImagePreview = ImageSelection & { + component: ImageComponent; + revision: string; + requestedImage: string; +}; +export class ImageApiError extends Error { + constructor( + public reason: string, + public status = 502, + ) { + super(reason); + } +} +function object(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +async function json(response: Response): Promise { + let data: unknown; + try { + data = await response.json(); + } catch { + throw new ImageApiError("invalid_response", response.status); + } + if (!response.ok) { + const reason = + object(data) && + object(data.detail) && + typeof data.detail.reason === "string" + ? data.detail.reason + : [401, 403].includes(response.status) + ? "forbidden" + : "read_failed"; + throw new ImageApiError(reason, response.status); + } + return data; +} +export async function imageDetail(response: Response): Promise { + const data = await json(response); + if ( + !object(data) || + !["runtime", "worker"].includes(String(data.component)) || + [ + "resourceId", + "repository", + "currentImage", + "status", + "revision", + "reason", + ].some((key) => typeof data[key] !== "string") || + typeof data.canUpdate !== "boolean" || + !( + data.operation === null || + (object(data.operation) && + typeof data.operation.target === "string" && + [ + "submitting", + "submitted", + "uncertain", + "configured", + "rejected", + ].includes(String(data.operation.phase))) + ) + ) + throw new ImageApiError("invalid_response"); + return data as ImageDetail; +} +export async function imageVersions( + response: Response, +): Promise { + const data = await json(response); + if ( + !object(data) || + typeof data.repository !== "string" || + !Array.isArray(data.tags) || + data.tags.some( + (tag) => typeof tag !== "string" || !/^[\w][\w.-]{0,127}$/.test(tag), + ) || + typeof data.nextCursor !== "string" + ) + throw new ImageApiError("invalid_response"); + return data as ImageVersions; +} + +export type ImageUpdateSource = + | { image: string; tag?: never } + | { tag: string; image?: never; selection?: ImageSelection }; + +export async function latestImagePreview( + response: Response, +): Promise { + const data = await json(response); + if ( + !object(data) || + !["runtime", "worker"].includes(String(data.component)) || + typeof data.revision !== "string" || + typeof data.requestedImage !== "string" || + !data.requestedImage.endsWith(":latest") || + typeof data.image !== "string" || + !validImageReference(data.image) || + data.image === data.requestedImage || + data.image.slice(0, data.image.lastIndexOf(":")) !== + data.requestedImage.slice(0, -7) || + typeof data.digest !== "string" || + !/^sha256:[a-f0-9]{64}$/.test(data.digest) + ) + throw new ImageApiError("invalid_response"); + return data as LatestImagePreview; +} +const imageHost = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"; +const imagePart = "[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*"; +const imageReference = new RegExp( + `^(${imageHost}(?:\\.${imageHost})*)(?::([0-9]{1,5}))?/${imagePart}(?:/${imagePart})*:[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`, +); +export function validImageReference(value: string): boolean { + if (value.length > 2048) return false; + const match = imageReference.exec(value); + if (!match || match[0].length !== value.length) return false; + const [, host, port] = match; + return ( + (!port || (Number(port) >= 1 && Number(port) <= 65535)) && + (host.includes(".") || Boolean(port) || host === "localhost") + ); +} diff --git a/frontend/src/i18n/resources/en-US/collaboratingAgents.json b/frontend/src/i18n/resources/en-US/collaboratingAgents.json new file mode 100644 index 000000000..4e9546ec0 --- /dev/null +++ b/frontend/src/i18n/resources/en-US/collaboratingAgents.json @@ -0,0 +1,178 @@ +{ + "title": "Collaborating Agents", + "close": "Close", + "refresh": "Refresh", + "add": "Add Agent", + "loading": "Working…", + "unsupported": "This Runtime does not support collaborating Agents. Upgrade its image.", + "forbidden": "Administrator access and a valid Runtime credential are required.", + "conflict": "Configuration changed. Refresh before editing again.", + "invalid": "Invalid configuration. Check the URL, authentication and credential.", + "failed": "Request failed. Check the Runtime connection and refresh to retry.", + "empty": "No collaborating Agents registered.", + "enabled": "Enabled", + "disabled": "Disabled", + "not_checked": "Not checked", + "verified": "Agent Card verified", + "checkFailed": "Connection check failed. Check the configuration and retry.", + "discoveryOnly": "Discovery verified. Task execution has not been verified.", + "capabilities": "Capabilities", + "none": "None", + "check": "Check connection", + "edit": "Edit", + "remove": "Remove", + "removeConfirm": "Remove this registration? The target Agent will not be deleted.", + "confirmRemove": "Confirm removal", + "cancel": "Cancel", + "name": "Name", + "description": "Purpose", + "cardUrl": "Agent Card URL", + "authentication": "Authentication", + "secretHelp": "Enter the target Agent token or API key. Saved credentials are never displayed.", + "header": "API key header name", + "save": "Save", + "reasons": { + "credential_unavailable": "Credential unavailable. Edit and enter it again.", + "destination_not_allowed": "Destination blocked. Private destinations require MPA_COLLABORATOR_PRIVATE_ORIGINS.", + "destination_unreachable": "Destination unreachable. Check DNS, network and TLS.", + "connection_timeout": "Connection timed out. Check the service and retry.", + "remote_auth_rejected": "Authentication rejected. Check credentials and permissions.", + "remote_card_http_error": "Agent Card request failed. Check its URL and service; redirects are not supported.", + "card_too_large": "Agent Card exceeds the size limit.", + "invalid_card_or_destination": "Invalid destination or Agent Card.", + "invalid_agent_card": "Response is not a valid A2A Agent Card.", + "unsupported_protocol": "Unsupported A2A protocol version.", + "unsupported_transport": "Only JSONRPC transport is supported.", + "card_endpoint_origin_mismatch": "Agent Card endpoint must share the configured origin.", + "card_metadata_too_large": "Agent Card metadata exceeds limits.", + "unsupported_card_authentication": "Configured authentication does not satisfy the Agent Card.", + "credential_reflected_in_card": "Agent Card reflected a credential and was rejected.", + "credential_store_unavailable": "Credential storage unavailable. Ask an administrator to check the database and encryption key." + }, + "missing": "This registration was removed. Refresh the list.", + "check_failed": "Connection check failed", + "uncertain": "Operation outcome is uncertain. Refresh to reconcile before making another change.", + "secret": "Secret", + "savedSecretHelp": "A credential is saved. Leave blank to keep it, or enter a new value to replace it.", + "keepSecret": "Configured; leave blank to keep", + "showSecret": "Show", + "hideSecret": "Hide", + "enabledHelp": "Allow this MPA to dispatch tasks to this Agent; disabling retains configuration.", + "center": { + "title": "A2A Center", + "help": "This MPA can call running agents in the selected space, including future additions. Resources belong to the target Runtime account and region.", + "current": "Connected space", + "notConnected": "Not connected", + "space": "A2A space", + "connect": "Connect selected space", + "disconnect": "Disconnect", + "disconnectConfirm": "Stop new dispatches from this MPA to the space? No space or agent will be deleted.", + "noSpaces": "No accessible A2A spaces in this account and region.", + "noAgents": "No agents in this space.", + "running": "Running", + "unavailable": "Unavailable", + "failed": "Cannot load A2A Center. Check Runtime AgentKit query permissions and network, then retry.", + "forbidden": "Access denied. Check Studio management and Runtime query permissions.", + "refreshRequired": "Save outcome is unconfirmed or configuration changed. Refresh before retrying.", + "previous": "Previous", + "next": "Next", + "spacesPages": "Space pages", + "agentsPages": "Agent pages", + "bindingTitle": "Space binding", + "membersTitle": "Space members", + "viewing": "Viewing", + "nameUnavailable": "Space name unavailable" + }, + "registration": { + "open": "Register Agent", + "source": "Registration source", + "runtime": "Existing Runtime", + "standard": "Import Agent Card", + "runtimeId": "Runtime ID", + "knownRuntime": "Choose an existing Runtime", + "chooseRuntime": "Select a Runtime", + "runtimeListFailed": "Unable to load the Runtime list. Check access permissions and retry.", + "network": "Runtime endpoint", + "public": "Public address", + "private": "Private address", + "importType": "Import method", + "cardJson": "Agent Card JSON", + "cardUrl": "Agent Card URL", + "submit": "Register in A2A Center", + "creating": "Registering…", + "created": "Registered in A2A Center", + "invalidCard": "Enter a JSON object containing name, url and version.", + "invalid": "Check the source, selected Runtime and Agent Card. Card input must be within 128 KB.", + "failed": "Registration rejected. Check access to the space and source.", + "uncertain": "Registration outcome is unknown. Close this form and refresh the center to check before creating another Agent.", + "destination": "Register in", + "runtimeExists": "This Runtime is already registered in A2A Center. Check its existing space; deregister the existing record before registering in another space.", + "endpointsLoading": "Loading access endpoints…", + "endpointsFailed": "Unable to read Runtime access endpoints. Check access permissions and try again.", + "noEndpoints": "This Runtime has no available public or private address.", + "endpointRequired": "Select a Runtime and load an available access endpoint first.", + "noRuntimes": "No Runtimes are available to select in this region." + }, + "spaceCreation": { + "open": "New space", + "name": "Space name", + "project": "Project name", + "intent": "Enable semantic search", + "intentHelp": "Find matching Agents in this space from the calling intent.", + "submit": "Create space", + "creating": "Creating space…", + "created": "Space created", + "invalid": "Enter valid space and project names, each up to 128 characters.", + "failed": "Could not create the space. Check the account, region and create permission, then retry.", + "uncertain": "Space creation is unconfirmed. Refresh and check the space list before creating another space.", + "forbidden": "Space creation is not authorized. Check Studio management access, Runtime access and AgentKit space creation permission." + }, + "member": { + "view": "View", + "unregister": "Deregister", + "confirmUnregister": "Confirm deregistration", + "unregisterHelp": "All callers will lose discovery and invocation of this Agent through this space. Its source Runtime and service will remain.", + "unregistered": "Agent deregistered", + "status": "Registration status", + "registered": "Registered", + "failed": "Registration failed", + "unknownStatus": "Unknown status", + "version": "Default version", + "loadFailed": "Could not load Agent details. Check access and connectivity, then retry.", + "serviceAddress": "Service address", + "addressUnavailable": "Service address unavailable", + "skills": "Skills", + "noSkills": "This Agent declares no skills.", + "skillsUnavailable": "AgentCard skill information is currently unavailable.", + "skillsTruncated": "Showing the first 100 skills.", + "tags": "Tags", + "examples": "Examples" + }, + "spaceDeletion": { + "open": "Delete space", + "confirm": "Confirm space deletion", + "help": "Space deletion cannot be undone. Other callers will lose access through this space. Confirm that dependent applications no longer use it.", + "deleted": "Space deleted", + "bound": "Disconnect this MPA from the space first.", + "nonempty": "Deregister all Agents in the space first.", + "loading": "Checking space members…" + }, + "actions": { + "uncertain": "The outcome is unconfirmed. Close the dialog and refresh the Center before another attempt.", + "forbidden": "This action is not authorized. Check Studio management and AgentKit action permissions.", + "rejected": "The action was rejected. Refresh and check the resource state before retrying.", + "preflightFailed": "Preflight checks failed; deregistration or deletion has not run. Check connectivity and retry.", + "runtimeAuthFailed": "Runtime authentication failed before deletion. Nothing was deleted. Refresh the Runtime connection and try again." + }, + "alias": { + "label": "Agent alias", + "empty": "Not set", + "edit": "Set alias", + "help": "Names this Agent within this space for the current MPA. Leave empty to clear. Use @helper followed by a task, or @\"Code Assistant\" for a name with spaces.", + "unavailable": "This Runtime does not support Agent aliases. Upgrade the Runtime and refresh.", + "invalid": "Use up to 80 letters, numbers, spaces, dots, underscores or hyphens.", + "conflict": "Another Agent in this space uses this alias. Choose a different name.", + "revisionConflict": "Someone changed this alias. Refresh before trying again.", + "failed": "The save result is uncertain. Refresh the list before trying again." + } +} diff --git a/frontend/src/i18n/resources/en-US/mpaImages.json b/frontend/src/i18n/resources/en-US/mpaImages.json new file mode 100644 index 000000000..0c299285f --- /dev/null +++ b/frontend/src/i18n/resources/en-US/mpaImages.json @@ -0,0 +1,54 @@ +{ + "title": "Image updates", + "runtime": "Runtime", + "worker": "Worker", + "refreshStatus": "Refresh status", + "currentImage": "Current image", + "targetImage": "Target image", + "cloudStatus": "Cloud status", + "notAvailable": "Unavailable", + "version": "Public repository version", + "selectVersion": "Select a public version", + "refreshVersions": "Refresh versions", + "loadMore": "Load more versions", + "loadingStatus": "Loading image status…", + "loadingVersions": "Loading public versions…", + "empty": "No public versions found", + "update": "Update image", + "confirm": "Confirm update", + "cancel": "Cancel", + "close": "Close", + "submitting": "Submitting…", + "confirmation": "Update {{component}} image", + "confirmHelp": "Only {{component}} will be updated. The other component remains unchanged.", + "runtimeHelp": "Runtime updates request immediate release. Submission does not confirm application health.", + "workerHelp": "Existing sandbox sessions may keep their current Worker image. Create a new session to use the updated template.", + "phases": { + "submitting": "Update submission is in progress. Refresh status to reconcile.", + "submitted": "Submitted. Refresh status to verify the configured image and cloud status.", + "uncertain": "The update outcome could not be confirmed. Refresh status before another update.", + "configured": "Target image configuration confirmed. Application health still needs verification.", + "rejected": "The previous update was rejected." + }, + "errors": { + "forbidden": "Permission denied. Check Studio management and AgentKit resource permissions.", + "stale": "Image configuration changed. Refresh status and select the version again.", + "busy": "The resource or an image update is still processing. Refresh status before updating.", + "ownership": "This resource must be exclusively owned and consistently bound to this MPA before it can be updated.", + "missing_worker": "No Worker template is bound to this Runtime.", + "read_failed": "Unable to read image status. Check the management database, cloud credentials and permissions, then refresh status.", + "catalog_failed": "Unable to load public image versions. Check registry connectivity and refresh versions.", + "rejected": "The update was rejected. Refresh status and check resource state and permissions.", + "unchanged": "The selected image is already configured. Choose another version.", + "outcome_unknown": "The update outcome could not be confirmed. Refresh status before another update; closing this dialog does not cancel a cloud update already submitted.", + "invalid_image": "Enter a complete image address, such as registry.example.com/namespace/image:tag. Do not include a protocol, credentials, or URL parameters.", + "latest_resolution_failed": "Unable to resolve or verify latest. Check public registry connectivity and version availability, then close and reopen this dialog to resolve again." + }, + "manualImage": "Image address (manual)", + "imagePlaceholder": "registry.example.com/namespace/image:tag", + "sourceHelp": "Enter an image address or choose a public repository version. Using either clears the other.", + "requestedImage": "Requested version", + "digest": "Content digest", + "resolvingLatest": "Resolving latest…", + "retryResolution": "Retry resolution" +} diff --git a/frontend/src/i18n/resources/en-US/ui.json b/frontend/src/i18n/resources/en-US/ui.json index aad878690..a719358ed 100644 --- a/frontend/src/i18n/resources/en-US/ui.json +++ b/frontend/src/i18n/resources/en-US/ui.json @@ -1676,6 +1676,50 @@ "iamTemporaryFailure": "IAM requests temporarily failed or timed out after retries. Check the Studio network connection and IAM service, then retry with the same agent ID.", "iamVerificationFailed": "Runtime role permissions could not be confirmed. Check IAM availability and policies, then retry.", "iamConfigurationConflict": "Automatic runtime role preparation requires the default MPA role. Check the server creation configuration.", + "failureDetails": { + "exceptionType": "Exception type", + "httpStatus": "HTTP status", + "responseType": "Response type", + "details": "Error details", + "copy": "Copy error information", + "copied": "Copied", + "copyFailed": "Copy failed. You can select and copy the details manually.", + "stage": "Failed stage", + "operation": "Failed operation", + "errorCode": "Error code", + "reasons": { + "iamTrustConflict": "The runtime role trust policy conflicts with the required configuration.", + "iamPolicyConflict": "The runtime role permission policy conflicts with the required configuration.", + "tosBucketUnavailable": "Availability of the selected TOS bucket could not be confirmed.", + "nameCheckUnavailable": "Resource name availability could not be checked.", + "iamVerification": "Runtime role permissions have not been verified.", + "modelEndpoints": "Model inference endpoint configuration is invalid.", + "modelConfiguration": "OpenViking rejected the model configuration.", + "creationUnconfirmed": "The OpenViking creation result is unconfirmed.", + "quota": "The resource quota has been reached.", + "permission": "The current credentials do not authorize this operation or are no longer valid.", + "nameConflict": "The resource name is already in use.", + "databaseAuthentication": "Database authentication failed.", + "databasePermission": "The database account is not authorized to perform this operation.", + "timeout": "The operation timed out; its outcome is not confirmed.", + "connection": "The resource service could not be reached; the operation outcome is not confirmed.", + "throttled": "The resource service rate-limited the request.", + "unavailable": "The resource service is temporarily unavailable.", + "invalid_request": "Request parameters or resource configuration failed validation.", + "not_found": "A required resource was not found.", + "provider_error": "The cloud resource operation failed without further safely displayable details.", + "ownership": "Resource ownership does not match this agent.", + "metadata_pending": "Resource ownership metadata is not ready.", + "metadata_missing": "Required resource ownership metadata is missing.", + "resource_failed": "The cloud resource entered a failed state without further details.", + "configuration_changed": "The current configuration differs from the registered creation configuration.", + "deployment_error": "Resource preparation did not complete; no further safely displayable details are available.", + "unknown": "Creation failed without further safely displayable details.", + "child_exit": "The creation process exited unexpectedly without further details.", + "interrupted": "The creation process was interrupted.", + "protocol_error": "The resource service returned an unexpected response format." + } + }, "creationFailed": "This step failed. Check its resources, server configuration and permissions, then retry the same agent ID.", "configurationChanged": "This agent's creation configuration no longer matches the original submission. Restore the original settings before retrying, or create another agent request.", "cancelFailed": "Cancellation failed. Check the current progress and retry.", diff --git a/frontend/src/i18n/resources/en-US/workspaceTools.json b/frontend/src/i18n/resources/en-US/workspaceTools.json index 827176439..e598be785 100644 --- a/frontend/src/i18n/resources/en-US/workspaceTools.json +++ b/frontend/src/i18n/resources/en-US/workspaceTools.json @@ -341,7 +341,18 @@ "discardDocument": "Discard unsaved changes?", "keepEditing": "Keep editing", "discardChanges": "Discard changes", - "saveDocument": "Save new version" + "saveDocument": "Save new version", + "editAgentsMd": "Edit", + "agentsMdEditHint": "Changes apply to future turns for this agent. Replies in progress keep their current configuration.", + "agentsMdConflict": "Another editor changed this configuration. Refresh and review it before saving.", + "agentsMdEditUnsupported": "This Runtime cannot edit AGENTS.md. Upgrade its image to enable editing.", + "agentsMdClearConfirm": "Saving an empty document removes custom instructions. Continue?", + "confirmDocument": "Confirm", + "agentsMdSaveUncertain": "Save status is uncertain. Refresh to check the persisted content.", + "agentsMdTooLong": "AGENTS.md supports up to 40,000 characters. Shorten it before saving.", + "agentsMdLoading": "Loading AGENTS.md…", + "agentsMdLoadFailed": "Unable to load AGENTS.md. Refresh to retry.", + "saveAgentsMd": "Save" }, "sessionEnvironment": { "mountFailed": "Could not mount the environment", diff --git a/frontend/src/i18n/resources/zh-CN/collaboratingAgents.json b/frontend/src/i18n/resources/zh-CN/collaboratingAgents.json new file mode 100644 index 000000000..fe1f074c1 --- /dev/null +++ b/frontend/src/i18n/resources/zh-CN/collaboratingAgents.json @@ -0,0 +1,178 @@ +{ + "title": "协作 Agent", + "close": "关闭", + "refresh": "刷新", + "add": "添加 Agent", + "loading": "正在处理…", + "unsupported": "当前 Runtime 不支持协作 Agent 管理,请升级镜像。", + "forbidden": "没有管理权限,请使用管理员账号并检查 Runtime 凭据。", + "conflict": "配置已被更新,请刷新后重新编辑。", + "invalid": "配置无效,请检查地址、鉴权方式和密钥。", + "failed": "请求失败,请检查 Runtime 连接后刷新重试。", + "empty": "尚未关联协作 Agent。", + "enabled": "启用", + "disabled": "停用", + "not_checked": "未验证", + "verified": "Agent Card 已验证", + "checkFailed": "连接验证失败,请检查配置后重试。", + "discoveryOnly": "连接验证通过,任务执行尚未验证。", + "capabilities": "能力", + "none": "无", + "check": "验证连接", + "edit": "编辑", + "remove": "移除", + "removeConfirm": "确认解除关联?目标 Agent 不会被删除。", + "confirmRemove": "确认移除", + "cancel": "取消", + "name": "名称", + "description": "用途说明", + "cardUrl": "Agent Card 地址", + "authentication": "鉴权方式", + "secretHelp": "填写目标 Agent 的 Token 或 API Key,保存后不回显。", + "header": "API Key 请求头名称", + "save": "保存", + "reasons": { + "credential_unavailable": "密钥不可用,请编辑并重新填写。", + "destination_not_allowed": "目标地址不在允许访问的网络范围内。私网目标需配置 MPA_COLLABORATOR_PRIVATE_ORIGINS。", + "destination_unreachable": "无法连接目标,请检查域名、网络与证书。", + "connection_timeout": "连接超时,请检查目标服务后重试。", + "remote_auth_rejected": "目标拒绝认证,请检查密钥与权限。", + "remote_card_http_error": "Agent Card 请求失败,请检查地址和服务状态;不支持跳转。", + "card_too_large": "Agent Card 超出大小限制。", + "invalid_card_or_destination": "目标地址或 Agent Card 格式无效。", + "invalid_agent_card": "响应不是有效的 A2A Agent Card。", + "unsupported_protocol": "暂不支持此 A2A 协议版本。", + "unsupported_transport": "暂仅支持 JSONRPC 传输。", + "card_endpoint_origin_mismatch": "Agent Card 声明的执行地址与配置地址不同源。", + "card_metadata_too_large": "Agent Card 能力信息超出限制。", + "unsupported_card_authentication": "配置的鉴权方式不满足 Agent Card 要求。", + "credential_reflected_in_card": "Agent Card 包含凭据回显,已拒绝保存。", + "credential_store_unavailable": "密钥存储不可用,请联系管理员检查数据库及加密密钥。" + }, + "missing": "该记录已被移除,请刷新列表。", + "check_failed": "连接验证失败", + "uncertain": "操作结果尚未确认。请先刷新核对,避免重复添加或修改。", + "secret": "密钥", + "savedSecretHelp": "已保存密钥。留空保留,填写新值则替换。", + "keepSecret": "已配置,留空保留", + "showSecret": "显示", + "hideSecret": "隐藏", + "enabledHelp": "允许此 MPA 派发任务给该 Agent,停用后保留配置。", + "center": { + "title": "A2A 中心", + "help": "选择空间后,该 MPA 可调用其中运行中的 Agent,包括后续新增的 Agent。资源来自当前 Runtime 所在账号和地域。", + "current": "已接入空间", + "notConnected": "未接入", + "space": "A2A 空间", + "connect": "接入所选空间", + "disconnect": "解除关联", + "disconnectConfirm": "解除后,该 MPA 将无法从此空间派发新任务。不会删除空间或 Agent。", + "noSpaces": "当前账号和地域没有可访问的 A2A 空间。", + "noAgents": "该空间暂无 Agent。", + "running": "运行中", + "unavailable": "不可调用", + "failed": "无法加载 A2A 中心,请检查 Runtime 的 AgentKit 查询权限和网络后重试。", + "forbidden": "无权访问 A2A 中心,请检查 Studio 管理权限和 Runtime 的查询权限。", + "refreshRequired": "保存结果未确认或配置已变化,请刷新后重试。", + "previous": "上一页", + "next": "下一页", + "spacesPages": "空间分页", + "agentsPages": "Agent 分页", + "bindingTitle": "空间绑定", + "membersTitle": "空间成员", + "viewing": "正在查看", + "nameUnavailable": "空间名称暂不可用" + }, + "registration": { + "open": "注册 Agent", + "source": "注册来源", + "runtime": "已有 Runtime", + "standard": "导入 Agent Card", + "runtimeId": "Runtime ID", + "knownRuntime": "选择已有 Runtime", + "chooseRuntime": "请选择 Runtime", + "runtimeListFailed": "无法加载 Runtime 列表,请检查访问权限后重试。", + "network": "Runtime 访问端点", + "public": "公网地址", + "private": "私网地址", + "importType": "导入方式", + "cardJson": "Agent Card JSON", + "cardUrl": "Agent Card 地址", + "submit": "注册到 A2A 中心", + "creating": "正在注册…", + "created": "已注册到 A2A 中心", + "invalidCard": "请输入包含 name、url 和 version 的 JSON 对象。", + "invalid": "请检查注册来源、所选 Runtime 和 Agent Card,输入不能超过 128 KB。", + "failed": "注册被拒绝,请检查空间和来源的访问权限。", + "uncertain": "注册结果尚未确认。请关闭表单并刷新中心,核实结果后再发起新的注册。", + "destination": "注册到", + "runtimeExists": "该 Runtime 已在 A2A 中心注册。请查看原空间中的记录;如需更换空间,请先注销原注册。", + "endpointsLoading": "正在加载访问端点…", + "endpointsFailed": "无法读取 Runtime 访问端点,请检查访问权限并重试。", + "noEndpoints": "该 Runtime 暂无可用的公网或私网地址。", + "endpointRequired": "请先选择 Runtime 并加载可用的访问端点。", + "noRuntimes": "当前地域暂无可选择的 Runtime。" + }, + "spaceCreation": { + "open": "新建空间", + "name": "空间名称", + "project": "项目名称", + "intent": "开启语义检索", + "intentHelp": "根据调用意图检索空间内匹配的 Agent。", + "submit": "创建空间", + "creating": "正在创建空间…", + "created": "空间已创建", + "invalid": "请填写有效的空间名称和项目名称,每项不超过 128 个字符。", + "failed": "无法创建空间,请检查账号、地域和创建权限后重试。", + "uncertain": "空间创建结果尚未确认。请先刷新核对空间列表,再决定是否重新创建。", + "forbidden": "无权创建空间,请检查 Studio 管理权限、Runtime 访问权限和 AgentKit 空间创建权限。" + }, + "member": { + "view": "查看", + "unregister": "注销", + "confirmUnregister": "确认注销", + "unregisterHelp": "注销后,所有调用方将无法通过此空间发现或调用该 Agent。来源 Runtime 和服务会保留。", + "unregistered": "Agent 已注销", + "status": "注册状态", + "registered": "注册成功", + "failed": "注册失败", + "unknownStatus": "未知状态", + "version": "默认版本", + "loadFailed": "无法加载 Agent 详情,请检查访问权限和网络后重试。", + "serviceAddress": "服务地址", + "addressUnavailable": "服务地址暂不可用", + "skills": "技能", + "noSkills": "该 Agent 未声明技能。", + "skillsUnavailable": "暂时无法读取 AgentCard 技能信息。", + "skillsTruncated": "仅展示前 100 项技能。", + "tags": "标签", + "examples": "示例" + }, + "spaceDeletion": { + "open": "删除空间", + "confirm": "确认删除空间", + "help": "删除空间不可恢复,其他调用方将无法再使用该空间。请确认相关业务已停止使用。", + "deleted": "空间已删除", + "bound": "请先解除当前 MPA 与此空间的关联。", + "nonempty": "请先注销空间内的全部 Agent。", + "loading": "正在检查空间成员…" + }, + "actions": { + "uncertain": "操作结果尚未确认。请关闭弹窗并刷新中心核对,避免重复操作。", + "forbidden": "无权执行此操作,请检查 Studio 管理权限和 AgentKit 操作权限。", + "rejected": "操作被拒绝,请刷新并检查资源状态后重试。", + "preflightFailed": "前置检查失败,尚未执行注销或删除。请检查连接后重试。", + "runtimeAuthFailed": "删除前的 Runtime 认证失败,尚未执行删除。请刷新 Runtime 连接后重试。" + }, + "alias": { + "label": "Agent 别名", + "empty": "未设置", + "edit": "设置别名", + "help": "用于当前 MPA 中此空间下的 Agent,留空可清除。对话示例:@代码助手 检查这段代码;名称含空格时使用 @\"Code Assistant\"。", + "unavailable": "当前 Runtime 不支持 Agent 别名,请升级 Runtime 后刷新。", + "invalid": "别名最多 80 个字符,仅支持字母、数字、空格、点、下划线和短横线。", + "conflict": "此别名已被空间内其他 Agent 使用,请换一个名称。", + "revisionConflict": "别名已被其他人修改,请刷新后重试。", + "failed": "无法确认别名是否保存,请刷新列表后重试。" + } +} diff --git a/frontend/src/i18n/resources/zh-CN/mpaImages.json b/frontend/src/i18n/resources/zh-CN/mpaImages.json new file mode 100644 index 000000000..ebf5dacb2 --- /dev/null +++ b/frontend/src/i18n/resources/zh-CN/mpaImages.json @@ -0,0 +1,54 @@ +{ + "title": "镜像更新", + "runtime": "Runtime", + "worker": "Worker", + "refreshStatus": "刷新状态", + "currentImage": "当前镜像", + "targetImage": "目标镜像", + "cloudStatus": "云端状态", + "notAvailable": "暂不可用", + "version": "公共仓库版本", + "selectVersion": "选择公共镜像版本", + "refreshVersions": "刷新版本", + "loadMore": "加载更多版本", + "loadingStatus": "正在读取镜像状态…", + "loadingVersions": "正在加载公共版本…", + "empty": "公共仓库暂无版本", + "update": "更新镜像", + "confirm": "确认更新", + "cancel": "取消", + "close": "关闭", + "submitting": "正在提交…", + "confirmation": "更新 {{component}} 镜像", + "confirmHelp": "本次只更新 {{component}},另一组件保持不变。", + "runtimeHelp": "Runtime 更新会请求立即发布。提交成功不代表应用已健康运行。", + "workerHelp": "已有沙箱会话可能继续使用原 Worker 镜像。请新建会话使用更新后的模板。", + "phases": { + "submitting": "正在提交更新,请刷新状态核对结果。", + "submitted": "已提交,请刷新状态核对配置镜像和云端状态。", + "uncertain": "更新结果尚未确认,请先刷新状态再发起更新。", + "configured": "已确认目标镜像配置,仍需验证应用是否健康。", + "rejected": "上次更新已被拒绝。" + }, + "errors": { + "forbidden": "无权操作,请检查 Studio 管理权限和 AgentKit 资源权限。", + "stale": "镜像配置已变化,请刷新状态后重新选择版本。", + "busy": "资源或镜像更新仍在处理中,请刷新状态后再更新。", + "ownership": "此资源须能证明由当前 MPA 独占且绑定一致,才能更新。", + "missing_worker": "此 Runtime 未绑定 Worker 模板。", + "read_failed": "读取镜像状态失败,请检查管理数据库、云凭据及权限后刷新状态。", + "catalog_failed": "加载公共镜像版本失败,请检查仓库网络连接后刷新版本。", + "rejected": "更新被拒绝,请刷新状态并检查资源状态和权限。", + "unchanged": "所选镜像已配置,请选择其他版本。", + "outcome_unknown": "更新结果尚未确认,请先刷新状态再发起更新。关闭弹窗不会取消已提交的云更新。", + "invalid_image": "请输入完整镜像地址,例如 registry.example.com/namespace/image:tag,不含协议、凭据或 URL 参数。", + "latest_resolution_failed": "无法解析或校验 latest。请检查公共仓库连接及版本是否可用,关闭并重新打开确认框后再试。" + }, + "manualImage": "镜像地址(手动填写)", + "imagePlaceholder": "registry.example.com/namespace/image:tag", + "sourceHelp": "手填镜像地址或选择公共仓库版本,二选一。使用其中一种会清空另一种。", + "requestedImage": "所选版本", + "digest": "内容摘要", + "resolvingLatest": "正在解析 latest…", + "retryResolution": "重新解析" +} diff --git a/frontend/src/i18n/resources/zh-CN/ui.json b/frontend/src/i18n/resources/zh-CN/ui.json index 41464b478..a9b2216d9 100644 --- a/frontend/src/i18n/resources/zh-CN/ui.json +++ b/frontend/src/i18n/resources/zh-CN/ui.json @@ -1676,6 +1676,50 @@ "iamTemporaryFailure": "IAM 请求在重试后仍暂时失败或超时,请检查 Studio 网络连接及 IAM 服务后,用同一智能体 ID 重试。", "iamVerificationFailed": "尚未确认运行角色的权限已就绪,请检查 IAM 服务及策略可用性后重试。", "iamConfigurationConflict": "自动准备运行角色仅支持默认 MPA 角色,请检查服务端创建配置。", + "failureDetails": { + "exceptionType": "异常类型", + "httpStatus": "HTTP 状态", + "responseType": "响应类型", + "details": "错误详情", + "copy": "复制错误信息", + "copied": "已复制", + "copyFailed": "复制失败,可手动选择详情文字复制。", + "stage": "失败阶段", + "operation": "失败操作", + "errorCode": "错误码", + "reasons": { + "iamTrustConflict": "运行角色的信任策略与所需配置不一致。", + "iamPolicyConflict": "运行角色的权限策略与所需配置不一致。", + "tosBucketUnavailable": "无法确认所选 TOS 存储桶可用。", + "nameCheckUnavailable": "未能完成资源名称占用检查。", + "iamVerification": "运行角色的权限尚未确认就绪。", + "modelEndpoints": "模型推理接入点配置无效。", + "modelConfiguration": "OpenViking 拒绝了模型调用配置。", + "creationUnconfirmed": "OpenViking 创建结果尚未确认。", + "quota": "该资源的配额已达到上限。", + "permission": "当前凭据没有执行该操作的权限,或凭据已失效。", + "nameConflict": "资源名称已被占用。", + "databaseAuthentication": "数据库连接认证失败。", + "databasePermission": "当前数据库账号没有执行该操作的权限。", + "timeout": "等待操作完成超时,当前结果尚未确认。", + "connection": "无法连接资源服务,当前操作结果尚未确认。", + "throttled": "资源服务限制了请求频率。", + "unavailable": "资源服务暂时不可用。", + "invalid_request": "请求参数或资源配置未通过校验。", + "not_found": "未找到所需资源。", + "provider_error": "云资源操作失败,未提供可安全展示的详细原因。", + "ownership": "资源归属与当前智能体不一致。", + "metadata_pending": "资源归属信息尚未就绪。", + "metadata_missing": "缺少所需的资源归属信息。", + "resource_failed": "云端资源进入失败状态,未返回更详细的原因。", + "configuration_changed": "当前配置与已登记的创建配置不一致。", + "deployment_error": "资源准备未完成,未提供可安全展示的详细原因。", + "unknown": "本次创建失败,未提供可安全展示的详细原因。", + "child_exit": "创建进程异常退出,未返回详细原因。", + "interrupted": "创建进程已中断。", + "protocol_error": "资源服务返回的数据格式不符合预期。" + } + }, "creationFailed": "当前步骤未完成,请检查对应资源、服务端配置及权限后,用同一智能体 ID 重试。", "configurationChanged": "此智能体的创建配置已与上次提交不一致。请恢复原配置后重试,或新建另一个智能体请求。", "cancelFailed": "取消请求失败,请重新查询进度后重试。", diff --git a/frontend/src/i18n/resources/zh-CN/workspaceTools.json b/frontend/src/i18n/resources/zh-CN/workspaceTools.json index 0586fa23b..4cd7910cf 100644 --- a/frontend/src/i18n/resources/zh-CN/workspaceTools.json +++ b/frontend/src/i18n/resources/zh-CN/workspaceTools.json @@ -341,7 +341,18 @@ "discardDocument": "放弃未保存的修改?", "keepEditing": "继续编辑", "discardChanges": "放弃修改", - "saveDocument": "保存为新版本" + "saveDocument": "保存为新版本", + "editAgentsMd": "编辑", + "agentsMdEditHint": "修改会应用于当前智能体的后续对话,正在进行的回复保持原配置。", + "agentsMdConflict": "配置已被其他页面修改,请刷新后核对,再保存。", + "agentsMdEditUnsupported": "当前 Runtime 不支持编辑 AGENTS.md,请升级 Runtime 镜像。", + "agentsMdClearConfirm": "清空后将不再使用自定义指令,确认保存?", + "confirmDocument": "确认", + "agentsMdSaveUncertain": "保存状态尚未确认,请刷新并核对持久化内容。", + "agentsMdTooLong": "AGENTS.md 最多支持 40,000 个字符,请缩短后保存。", + "agentsMdLoading": "正在加载 AGENTS.md…", + "agentsMdLoadFailed": "无法加载 AGENTS.md,请刷新重试。", + "saveAgentsMd": "保存" }, "sessionEnvironment": { "mountFailed": "挂载环境失败", diff --git a/frontend/src/ui/AgentTopology.tsx b/frontend/src/ui/AgentTopology.tsx index 8c4bb94ce..3d7623701 100644 --- a/frontend/src/ui/AgentTopology.tsx +++ b/frontend/src/ui/AgentTopology.tsx @@ -48,6 +48,7 @@ function ModuleTitle({ title, count }: ModuleTitleProps) { } export interface AgentInfoPanelProps { + onEditAgentsMd?: () => void; boundSkills?: BoundSkillState; onRefresh?: () => void; onOpenSkill?: (index: number) => void; @@ -74,6 +75,7 @@ export interface AgentInfoPanelProps { * right whitespace. The parent owns metadata loading so this display component * never issues a duplicate `/web/agent-info` request. */ export function AgentInfoPanel({ + onEditAgentsMd, info, loading, boundSkills, @@ -152,6 +154,11 @@ export function AgentInfoPanel({
+ {isMpa && onEditAgentsMd && ( + + )}
{ + if (item === "images") return imagesAvailable; + if (item === "collaborators") return collaboratorsAvailable; if (selectedAgentCategory === "mpa") return item === "basic"; if (item === "profileConfig") return false; if (item === "usage") return canViewUsage && selectedAgent?.runtimeId; @@ -2144,7 +2159,7 @@ export function AgentWorkspace({ }); const visibleAgentSections = visibleAgentSectionIds.map((id) => ({ id, - label: t(`agentWorkspace.sections.${id}`), + label: id === "images" ? t("mpaImages:title") : id === "collaborators" ? t("collaboratingAgents:title") : t(`agentWorkspace.sections.${id}`), })); const agentUsageRequestKey = JSON.stringify([ selectedAgent?.runtimeId ?? "", @@ -4421,6 +4436,22 @@ export function AgentWorkspace({
)}
+ {section === "images" && imagesAvailable && selectedAgent?.runtimeId && ( +
+

{t("mpaImages:title")}

+ +
+ )} + {section === "collaborators" && collaboratorsAvailable && selectedAgent?.runtimeId && ( +
+

{t("collaboratingAgents:title")}

+ +
+ )} {section === "basic" && (
{detailAgentInfoUnsupported && ( diff --git a/frontend/src/ui/collaborating-agents/A2AAliasEditor.tsx b/frontend/src/ui/collaborating-agents/A2AAliasEditor.tsx new file mode 100644 index 000000000..3fdaf29f5 --- /dev/null +++ b/frontend/src/ui/collaborating-agents/A2AAliasEditor.tsx @@ -0,0 +1,221 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Button, + FormLabel, + InputWithTailIcon, +} from "../../components/primitives"; +import { requestCollaborators } from "../../adk/client"; +import { aliasRecord, type AliasRecord } from "../../adk/a2aSpace"; +import { CollaboratorApiError } from "../../adk/collaboratingAgents"; + +type Props = { + runtimeId: string; + region: string; + spaceId: string; + agentId: string; + alias: string | null; + revision: number; + available: boolean; + disabled: boolean; + onBusy: (value: boolean) => void; + onSaved: (value: AliasRecord) => void; + onRefresh: () => void; +}; +export function A2AAliasEditor(props: Props) { + const { t } = useTranslation("collaboratingAgents"); + const id = useId(); + const [editing, setEditing] = useState(false); + const [saved, setSaved] = useState({ + alias: props.alias, + revision: props.revision, + }); + const [value, setValue] = useState(props.alias ?? ""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [uncertain, setUncertain] = useState(false); + const pending = useRef(null); + const composing = useRef(false); + const form = useRef(null); + const trigger = useRef(null); + const restoreFocus = useRef(false); + useEffect(() => () => pending.current?.abort(), []); + useEffect(() => { + setSaved({ alias: props.alias, revision: props.revision }); + setValue(props.alias ?? ""); + setUncertain(false); + setError(""); + }, [props.alias, props.revision]); + useEffect(() => { + if (editing) { + restoreFocus.current = true; + form.current?.querySelector("input")?.focus(); + } else if (restoreFocus.current && !busy && !props.disabled) { + trigger.current?.focus(); + restoreFocus.current = false; + } + }, [editing, busy, props.disabled]); + function close() { + if (pending.current) { + pending.current.abort(); + pending.current = null; + setBusy(false); + props.onBusy(false); + setUncertain(true); + setError("alias.failed"); + } + setEditing(false); + trigger.current?.focus(); + } + async function submit() { + if ( + pending.current || + uncertain || + composing.current || + props.disabled || + !props.available + ) + return; + const alias = value.normalize("NFKC").trim(); + if (alias.length > 80 || (alias && !/^[\p{L}\p{N} ._-]+$/u.test(alias))) { + setError("alias.invalid"); + return; + } + const controller = new AbortController(); + pending.current = controller; + setBusy(true); + props.onBusy(true); + setError(""); + try { + const record = await requestCollaborators( + props.runtimeId, + props.region, + `/registry/aliases/${encodeURIComponent(props.agentId)}`, + { + method: "PUT", + signal: controller.signal, + body: JSON.stringify({ + space_id: props.spaceId, + alias: alias || null, + revision: saved.revision, + }), + }, + ).then(aliasRecord); + if (controller.signal.aborted) return; + setSaved(record); + setValue(record.alias ?? ""); + setEditing(false); + props.onSaved(record); + trigger.current?.focus(); + } catch (reason: unknown) { + if (controller.signal.aborted) return; + const code = + reason instanceof CollaboratorApiError ? reason.reason : undefined; + setError( + code === "alias_conflict" + ? "alias.conflict" + : code === "revision_conflict" + ? "alias.revisionConflict" + : code === "invalid_alias" + ? "alias.invalid" + : "alias.failed", + ); + setUncertain(code !== "alias_conflict" && code !== "invalid_alias"); + } finally { + if (pending.current === controller) { + pending.current = null; + if (!controller.signal.aborted) { + setBusy(false); + props.onBusy(false); + } + } + } + } + return ( +
+
+ + {t("alias.label")}: {saved.alias || t("alias.empty")} + + +
+ {!props.available && ( +

{t("alias.unavailable")}

+ )} + {editing && ( +
{ + composing.current = true; + }} + onCompositionEnd={() => { + composing.current = false; + }} + onKeyDown={(event) => { + if ( + event.key === "Enter" && + (composing.current || event.nativeEvent.isComposing) + ) + event.preventDefault(); + if (event.key === "Escape" && !composing.current) { + event.preventDefault(); + close(); + } + }} + onSubmit={(event) => { + event.preventDefault(); + void submit(); + }} + > + {t("alias.label")} + setValue(event.target.value)} + /> +

+ {t("alias.help")} +

+ {error && ( + + )} +
+ + + {uncertain && ( + + )} +
+ + )} +
+ ); +} diff --git a/frontend/src/ui/collaborating-agents/A2ACenter.tsx b/frontend/src/ui/collaborating-agents/A2ACenter.tsx new file mode 100644 index 000000000..3396612e8 --- /dev/null +++ b/frontend/src/ui/collaborating-agents/A2ACenter.tsx @@ -0,0 +1,597 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, FormLabel, Select } from "../../components/primitives"; +import { requestCollaborators } from "../../adk/client"; +import { CollaboratorApiError } from "../../adk/collaboratingAgents"; +import { + registryPage, + spaceBinding, + type RegistryPage, + type RegistryItem, + type SpaceBinding, +} from "../../adk/a2aSpace"; +import { TextShimmer } from "../text-shimmer/TextShimmer"; + +import { A2AAliasEditor } from "./A2AAliasEditor"; +import { A2ARegistration } from "./A2ARegistration"; +import { A2ASpaceCreation } from "./A2ASpaceCreation"; +import { A2AMemberActions, A2ASpaceDeletion } from "./A2AResourceActions"; + +type Target = { runtimeId: string; region: string }; +export function A2ACenter({ runtimeId, region }: Target) { + const { t } = useTranslation("collaboratingAgents"); + const id = useId(); + const [binding, setBinding] = useState(null); + const [spaces, setSpaces] = useState(null); + const [agents, setAgents] = useState(null); + const [selected, setSelected] = useState(""); + const [loading, setBusy] = useState(false); + const [registering, setRegistering] = useState(false); + const [registrationOpen, setRegistrationOpen] = useState(false); + const [created, setCreated] = useState(""); + const registrationTrigger = useRef(null); + const returnFocus = useRef(false); + const [creationOpen, setCreationOpen] = useState(false); + const [creatingSpace, setCreatingSpace] = useState(false); + const [newSpace, setNewSpace] = useState(null); + const [spaceCreated, setSpaceCreated] = useState(false); + const [spaceUncertain, setSpaceUncertain] = useState(false); + const creationTrigger = useRef(null); + const creationReturnFocus = useRef(false); + const refreshTrigger = useRef(null); + const [resourceBusy, setResourceBusy] = useState(false); + const [resourceNotice, setResourceNotice] = useState(""); + const resourceReturnFocus = useRef(false); + const busy = loading || registering || creatingSpace || resourceBusy; + useEffect(() => { + if (!busy && resourceReturnFocus.current) { + refreshTrigger.current?.focus(); + resourceReturnFocus.current = false; + } + }, [busy, resourceNotice]); + useEffect(() => { + if (creationOpen) creationReturnFocus.current = true; + else if (!busy && creationReturnFocus.current) { + (creationTrigger.current?.disabled + ? refreshTrigger.current + : creationTrigger.current + )?.focus(); + creationReturnFocus.current = false; + } + }, [creationOpen, busy]); + useEffect(() => { + if (registrationOpen) returnFocus.current = true; + else if (!busy && returnFocus.current) { + (registrationTrigger.current?.disabled + ? refreshTrigger.current + : registrationTrigger.current + )?.focus(); + returnFocus.current = false; + } + }, [registrationOpen, busy]); + const [error, setError] = useState(""); + const [uncertain, setUncertain] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + const pending = useRef(null); + + async function perform( + work: (signal: AbortSignal) => Promise<() => void>, + mutation = false, + ) { + if (pending.current && !pending.current.signal.aborted) return; + const controller = new AbortController(); + pending.current = controller; + setBusy(true); + setError(""); + try { + const apply = await work(controller.signal); + if (!controller.signal.aborted) apply(); + } catch (reason: unknown) { + if (!controller.signal.aborted) { + if (mutation) setUncertain(true); + setError( + mutation + ? "center.refreshRequired" + : reason instanceof CollaboratorApiError && + [401, 403].includes(reason.status) + ? "center.forbidden" + : reason instanceof CollaboratorApiError && reason.status === 404 + ? "unsupported" + : "center.failed", + ); + } + } finally { + if (pending.current === controller) { + pending.current = null; + if (!controller.signal.aborted) setBusy(false); + } + } + } + function call(suffix: string, signal: AbortSignal, init: RequestInit = {}) { + return requestCollaborators(runtimeId, region, `/registry/${suffix}`, { + ...init, + signal, + }); + } + async function reload( + preferred = newSpace?.Id === selected ? newSpace : null, + ) { + setRegistrationOpen(false); + setCreationOpen(false); + await perform(async (signal) => { + const saved = await call("binding", signal).then(spaceBinding); + if (signal.aborted) return () => {}; + setBinding(saved); + setUncertain(false); + setDisconnecting(false); + setAgents(null); + const page = await call("spaces?page=1", signal).then(registryPage); + if (signal.aborted) return () => {}; + const chosen = preferred?.Id ?? saved.space_id ?? page.items[0]?.Id ?? ""; + setSpaces(page); + setSelected(chosen); + const list = chosen + ? await call( + `agents?space_id=${encodeURIComponent(chosen)}&page=1`, + signal, + ).then(registryPage) + : null; + return () => { + setBinding(saved); + setSpaceUncertain(false); + setSpaces(page); + setSelected(chosen); + setAgents(list); + setUncertain(false); + setDisconnecting(false); + }; + }); + } + useEffect(() => { + void reload(); + return () => pending.current?.abort(); + }, [runtimeId, region]); + function choose(value: string) { + setDisconnecting(false); + setResourceNotice(""); + setRegistrationOpen(false); + setCreationOpen(false); + setCreated(""); + setSelected(value); + setAgents(null); + void perform(async (signal) => { + const page = await call( + `agents?space_id=${encodeURIComponent(value)}&page=1`, + signal, + ).then(registryPage); + return () => setAgents(page); + }); + } + function save(spaceId: string | null) { + if ( + !binding || + uncertain || + (spaceId === null && binding.space_id !== selected) + ) + return; + void perform(async (signal) => { + const saved = await call("binding", signal, { + method: "PUT", + body: JSON.stringify({ space_id: spaceId, revision: binding.revision }), + }).then(spaceBinding); + return () => { + setBinding(saved); + setDisconnecting(false); + }; + }, true); + } + function page(kind: "spaces" | "agents", value: number) { + void perform(async (signal) => { + const result = await call( + `${kind}?page=${value}${kind === "agents" ? `&space_id=${encodeURIComponent(selected)}` : ""}`, + signal, + ).then(registryPage); + return () => { + if (kind === "spaces") setSpaces(result); + else setAgents(result); + }; + }); + } + function pagination(kind: "spaces" | "agents", result: RegistryPage | null) { + if (!result || result.total <= result.page_size) return null; + return ( +
+ + + {result.page} / {Math.ceil(result.total / result.page_size)} + + +
+ ); + } + const options = + spaces?.items.map((item) => ({ + value: item.Id, + label: item.Name || t("center.nameUnavailable"), + })) ?? []; + if (newSpace && !options.some((item) => item.value === newSpace.Id)) + options.push({ + value: newSpace.Id, + label: newSpace.Name || t("center.nameUnavailable"), + }); + const selectedSpace = + spaces?.items.find((item) => item.Id === selected) ?? + (newSpace?.Id === selected ? newSpace : null); + const destination = selectedSpace?.Name || t("center.nameUnavailable"); + const boundSpaceName = + binding?.space_name || + spaces?.items.find((item) => item.Id === binding?.space_id)?.Name || + (newSpace?.Id === binding?.space_id ? newSpace?.Name : null) || + t("center.nameUnavailable"); + if (selected && !options.some((item) => item.value === selected)) + options.unshift({ value: selected, label: t("center.nameUnavailable") }); + return ( +
+

+ {runtimeId} | {region} +

+ {busy && {t("loading")}} + {error &&

{t(error)}

} + {resourceNotice &&

{t(resourceNotice)}

} + {uncertain && !registrationOpen && !creationOpen && ( +

+ {t( + spaceUncertain + ? "spaceCreation.uncertain" + : "center.refreshRequired", + )} +

+ )} +
+

{t("center.bindingTitle")}

+

{t("center.help")}

+ {binding && ( +

+ {t("center.current")}:{" "} + {binding.space_id ? boundSpaceName : t("center.notConnected")} +

+ )} + {spaces && !spaces.items.length && !error && !newSpace && ( +

{t("center.noSpaces")}

+ )} +
+ {spaces && options.length > 0 && ( +
+ {t("center.space")} + { + setError(""); + setShowCard(false); + setForm( + event.target.value === "Runtime" + ? { source: "Runtime", runtimeId: "", networkType: "public" } + : { source: "Standard", importType: "json", json: "" }, + ); + }} + /> + {form.source === "Runtime" ? ( + <> + + {t("registration.knownRuntime")} + + ({ + value: item.networkType, + label: t(`registration.${item.networkType}`), + description: item.url, + }))} + onChange={(event) => + setForm({ + ...form, + networkType: + event.target.value === "private" ? "private" : "public", + }) + } + /> + {endpointLoading && ( + + {t("registration.endpointsLoading")} + + )} + {endpointError && ( +
+ {t("registration.endpointsFailed")}{" "} + +
+ )} + {!endpointLoading && + !endpointError && + endpointData?.runtimeId === sourceRuntimeId && + endpoints.length === 0 &&

{t("registration.noEndpoints")}

} + {selectedEndpoint && ( +

{selectedEndpoint.url}

+ )} + + ) : ( + <> + + {t("registration.importType")} + +