Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions frontend/src/adk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export interface AdkEndpoint {
apiKey?: string;
runtimeId?: string;
region?: string;
retryProbe?: boolean;
}

// Routing table for remote AgentKit apps: maps a dropdown id (see
Expand Down Expand Up @@ -313,8 +314,11 @@ async function apiFetch(
signal: requestSignal(init.signal, timeoutMs),
};
if (ep.runtimeId) {
const rq = ep.region
? `${path.includes("?") ? "&" : "?"}region=${encodeURIComponent(ep.region)}`
const runtimeParams = new URLSearchParams();
if (ep.region) runtimeParams.set("region", ep.region);
if (ep.retryProbe) runtimeParams.set("probe_retry", "connect");
const rq = runtimeParams.toString()
? `${path.includes("?") ? "&" : "?"}${runtimeParams.toString()}`
: "";
return fetch(
withAuth(`${API_BASE}/web/runtime-proxy/${ep.runtimeId}${path}${rq}`),
Expand Down Expand Up @@ -2093,9 +2097,12 @@ export async function getRuntimes(
export async function probeRuntimeApps(
runtimeId: string,
region: string,
options: { retryProbe?: boolean } = {},
): Promise<string[] | null> {
try {
const res = await fetchRemoteApps("", "", { runtimeId, region });
const endpoint: AdkEndpoint = { runtimeId, region };
if (options.retryProbe) endpoint.retryProbe = true;
const res = await fetchRemoteApps("", "", endpoint);
return res;
} catch (error) {
if (
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/adk/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ export async function connectRuntime(
let unsupportedError: RuntimeProbeError | null = null;
for (const candidate of runtimeRegionCandidates(region)) {
try {
const probedApps = await probeRuntimeApps(runtimeId, candidate);
const probedApps = await probeRuntimeApps(runtimeId, candidate, {
retryProbe: true,
});
if (probedApps && probedApps.length > 0) {
apps = probedApps;
resolvedRegion = candidate;
Expand Down
27 changes: 1 addition & 26 deletions frontend/src/ui/MyAgents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { SVGProps } from "react";

import {
getRuntimeAgentInfo,
getRuntimes,
type CloudRuntime,
type RuntimeScope,
Expand Down Expand Up @@ -33,7 +32,7 @@ const AGENT_TYPES: Array<{ id: AgentType; label: string; createLabel: string }>
{ id: "openclaw", label: "OpenClaw 智能体", createLabel: "添加 OpenClaw 智能体" },
{ id: "hermes", label: "Hermes 智能体", createLabel: "添加 Hermes 智能体" },
];
const RUNTIME_PAGE_SIZE = 100;
const RUNTIME_PAGE_SIZE = 24;
const RUNTIME_PAGE_CACHE_TTL_MS = 30_000;
const runtimePageRequests = new Map<
string,
Expand Down Expand Up @@ -166,30 +165,6 @@ async function loadRuntimeAgents(
expiresAt: Date.now() + RUNTIME_PAGE_CACHE_TTL_MS,
});
onList(page.runtimes.map(runtimeToAgent));
void Promise.all(
page.runtimes.map(async (runtime) => {
try {
const info = await getRuntimeAgentInfo(runtime.runtimeId, runtime.region);
const agent = {
id: runtime.runtimeId,
appName: info.appName,
name: info.name || runtime.name,
description: info.description || runtime.name,
createdAt: formatCreatedAt(runtime.createdAt ?? ""),
isMine: runtime.isMine,
runtime: {
runtimeId: runtime.runtimeId,
region: runtime.region,
currentVersion: runtime.currentVersion,
canDelete: runtime.canDelete,
},
};
onList([agent]);
} catch {
// Keep the Runtime fallback card already rendered above.
}
}),
);
return page.nextToken;
}

Expand Down
4 changes: 3 additions & 1 deletion frontend/src/ui/ProjectPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,9 @@ export function ProjectPreview({
// the browser; /web/runtime-proxy injects it.
const region = deployResult.region ?? deployRegion;
const apps =
(await probeRuntimeApps(deployResult.runtimeId, region)) ?? [];
(await probeRuntimeApps(deployResult.runtimeId, region, {
retryProbe: true,
})) ?? [];
conn = addRuntimeConnection(
deployResult.runtimeId,
deployResult.agentName,
Expand Down
2 changes: 1 addition & 1 deletion frontend/tests/manageAgentsConnection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ test("runtime connection probing is shared with the Agent selector", () => {
assert.match(connectionsSource, /export async function connectRuntime/);
assert.match(connectionsSource, /RUNTIME_REGION_FALLBACKS = \["cn-beijing", "cn-shanghai"\]/);
assert.match(connectionsSource, /for \(const candidate of runtimeRegionCandidates\(region\)\)/);
assert.match(connectionsSource, /probeRuntimeApps\(runtimeId, candidate\)/);
assert.match(connectionsSource, /probeRuntimeApps\(runtimeId, candidate,[\s\S]*?retryProbe: true/);
assert.match(connectionsSource, /resolvedRegion = candidate/);
assert.match(connectionsSource, /addRuntimeConnection\(/);
assert.match(connectionsSource, /resolvedRegion,[\s\S]*?apps,[\s\S]*?labels/);
Expand Down
7 changes: 5 additions & 2 deletions frontend/tests/myAgents.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ test("uses a compact three-column directory grid", () => {
test("creation time remains compact without data-plane metadata", () => {
assert.doesNotMatch(pageStyles, /\.my-agent-label/);
assert.match(pageStyles, /\.my-agent-created-at dd\s*\{[\s\S]*?font-weight: 400/);
assert.doesNotMatch(pageSource, /getRuntimeAgentInfo/);
assert.doesNotMatch(pageSource, /Promise\.all\([\s\S]*?page\.runtimes\.map/);
assert.doesNotMatch(pageSource, /appName: info\.appName/);
});

test("loads the runtime scope granted to the current role", () => {
Expand All @@ -117,7 +120,7 @@ test("loads the runtime scope granted to the current role", () => {
assert.match(pageSource, /runtimeId: runtime\.runtimeId/);
assert.match(pageSource, /region: runtime\.region/);
assert.match(pageSource, /<AgentCard[\s\S]*?key=\{agent\.id\}/);
assert.match(pageSource, /const RUNTIME_PAGE_SIZE = 100/);
assert.match(pageSource, /const RUNTIME_PAGE_SIZE = 24/);
assert.match(pageSource, /onList\(page\.runtimes\.map\(runtimeToAgent\)\)/);
assert.match(pageSource, /runtimeRequestRef\.current !== requestId/);
assert.match(pageSource, /const runtimePageRequests = new Map/);
Expand Down Expand Up @@ -239,7 +242,7 @@ test("wires card details and connect actions into App navigation", () => {
assert.match(appSource, /const detailAgentEntry:[\s\S]*?id: `detail:\$\{agentDetailTarget\.runtime\.runtimeId\}`/);
assert.match(appSource, /app: agentDetailTarget\.appName \?\? agentDetailTarget\.name/);
assert.match(pageSource, /appName\?: string/);
assert.match(pageSource, /appName: info\.appName/);
assert.doesNotMatch(pageSource, /appName: info\.appName/);
assert.match(appSource, /<MyAgents[\s\S]*?onCreateAgent=\{openAgentCreateFromMyAgents\}[\s\S]*?onUseAgent=/);
assert.match(appSource, /const openAgentCreateFromMyAgents = \(region: string\)[\s\S]*?setNewRuntimeRegion\(region\)/);
assert.match(appSource, /<CustomCreate[\s\S]*?initialDeployRegion=\{newRuntimeRegion\}/);
Expand Down
3 changes: 2 additions & 1 deletion frontend/tests/studioAccess.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ test("runtime authorization failures are not reported as unsupported", () => {
assert.match(cliFrontendSource, /endpoint_network_type == "private"[\s\S]*?runtime_private_endpoint_unreachable/);
assert.match(cliFrontendSource, /def _runtime_proxy_should_retry_probe[\s\S]*?normalized == "list-apps"[\s\S]*?normalized\.startswith\("web\/agent-info\/"\)/);
assert.match(cliFrontendSource, /parts\[0\] == "apps"[\s\S]*?parts\[2\] == "users"[\s\S]*?parts\[4\] == "sessions"/);
assert.match(cliFrontendSource, /max_attempts = 10 if retry_probe else 1/);
assert.match(cliFrontendSource, /endpoint_network_type == "private"[\s\S]*?return 1/);
assert.match(cliFrontendSource, /retry_mode == "connect"[\s\S]*?else 1/);
assert.match(cliFrontendSource, /runtime-proxy probe retry/);
assert.match(clientSource, /res\.status === 404[\s\S]*?RuntimeProbeError/);
assert.match(clientSource, /res\.status === 401 \|\| res\.status === 403/);
Expand Down
88 changes: 88 additions & 0 deletions tests/cli/test_frontend_runtime_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from types import SimpleNamespace
from typing import Any

import httpx
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
Expand Down Expand Up @@ -324,6 +325,93 @@ async def aclose(self) -> None:
assert upstream_headers["Authorization"] == expected_authorization


@pytest.mark.parametrize(
("network_type", "query", "expected_attempts"),
[
("private", "?region=cn-beijing&probe_retry=connect", 1),
("public", "?region=cn-beijing&probe_retry=connect", 3),
("public", "?region=cn-beijing", 1),
],
)
def test_runtime_proxy_probe_retry_policy(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
network_type: str,
query: str,
expected_attempts: int,
) -> None:
app = _create_frontend_app(monkeypatch, tmp_path)

async def _noop_sleep(delay: float) -> None:
pass

monkeypatch.setattr("veadk.cli.cli_frontend.asyncio.sleep", _noop_sleep)

class _FakeRuntimeClient:
def __init__(self, **kwargs: Any) -> None:
pass

def get_runtime(self, request: Any) -> SimpleNamespace:
return SimpleNamespace(
network_configurations=[
SimpleNamespace(
endpoint="https://runtime.example",
network_type=network_type,
)
],
authorizer_configuration=SimpleNamespace(
key_auth=SimpleNamespace(api_key="runtime-api-key"),
custom_jwt_authorizer=None,
),
)

monkeypatch.setattr(
"agentkit.sdk.runtime.client.AgentkitRuntimeClient",
_FakeRuntimeClient,
)

attempts = 0
forwarded_params: list[dict[str, str]] = []

class _FakeAsyncClient:
def __init__(self, **kwargs: Any) -> None:
pass

def build_request(
self,
method: str,
url: str,
*,
params: dict[str, str],
headers: dict[str, str],
content: bytes,
) -> object:
forwarded_params.append(params)
return object()

async def send(self, request: object, *, stream: bool) -> None:
nonlocal attempts
attempts += 1
raise httpx.ConnectError("connect failed")

async def aclose(self) -> None:
pass

monkeypatch.setattr("httpx.AsyncClient", _FakeAsyncClient)

with TestClient(app) as client:
response = client.get(f"/web/runtime-proxy/runtime-1/list-apps{query}")

assert response.status_code == 502
assert attempts == expected_attempts
assert forwarded_params == [{}] * expected_attempts
assert response.json()["detail"] == (
"runtime_private_endpoint_unreachable"
if network_type == "private"
else "runtime_proxy_connect_error"
)


def test_runtime_proxy_resolves_studio_media_before_forwarding(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
Expand Down
30 changes: 25 additions & 5 deletions veadk/cli/cli_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -3803,6 +3803,18 @@ def _runtime_proxy_should_retry_probe(method: str, path: str) -> bool:
def _runtime_proxy_retry_delay(attempt: int) -> float:
return min(5.0, float(2 ** max(0, attempt - 1)))

def _runtime_proxy_probe_attempts(
request: Request,
path: str,
endpoint_network_type: str,
) -> int:
if not _runtime_proxy_should_retry_probe(request.method, path):
return 1
if endpoint_network_type == "private":
return 1
retry_mode = request.query_params.get("probe_retry", "")
return 3 if retry_mode == "connect" else 1

def _runtime_network_error_detail(
endpoint_network_type: str,
*,
Expand Down Expand Up @@ -3971,8 +3983,12 @@ async def _runtime_proxy(runtime_id: str, path: str, request: Request):
logger.error(f"resolve runtime conn failed: {e}", exc_info=True)
raise HTTPException(status_code=502, detail=str(e))

# Drop the SSO gateway querystring; keep any real API query params.
qs = {k: v for k, v in request.query_params.items() if k != "region"}
# Drop Studio-only query params; keep any real API query params.
qs = {
k: v
for k, v in request.query_params.items()
if k not in {"region", "probe_retry"}
}
target = f"{endpoint.rstrip('/')}/{path}"
target_host = _runtime_endpoint_host(target)
logger.info(
Expand Down Expand Up @@ -4019,9 +4035,13 @@ async def _runtime_proxy(runtime_id: str, path: str, request: Request):

from fastapi.responses import StreamingResponse

retry_probe = _runtime_proxy_should_retry_probe(request.method, path)
max_attempts = 10 if retry_probe else 1
timeout = httpx.Timeout(10.0, connect=5.0) if retry_probe else None
is_probe_request = _runtime_proxy_should_retry_probe(request.method, path)
max_attempts = _runtime_proxy_probe_attempts(
request,
path,
endpoint_network_type,
)
timeout = httpx.Timeout(10.0, connect=5.0) if is_probe_request else None

# Open the upstream stream so we can forward status + body incrementally.
client = httpx.AsyncClient(timeout=timeout)
Expand Down

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading