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
63 changes: 61 additions & 2 deletions agentrun/agent_runtime/__client_async_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
)
from typing_extensions import Unpack

from agentrun.agent_runtime._workspace import (
resolve_workspace_id_by_name,
resolve_workspace_id_by_name_async,
resolve_workspace_ids_by_names,
resolve_workspace_ids_by_names_async,
)
from agentrun.agent_runtime.api.data import InvokeArgs
from agentrun.agent_runtime.model import (
AgentRuntimeArtifact,
Expand Down Expand Up @@ -68,11 +74,29 @@ async def create_async(
AgentRuntime: 创建的 Agent Runtime 对象 / Created Agent Runtime object

Raises:
ValueError: 当既未提供代码配置也未提供容器配置时 / When neither code nor container configuration is provided
ValueError: 当既未提供代码配置也未提供容器配置时;或同时传入
workspace_id 与 workspace_name / When neither code nor container
configuration is provided, or when workspace_id and workspace_name
are both set
ResourceAlreadyExistError: 资源已存在 / Resource already exists
ResourceNotExistError: 资源不存在 / Resource does not exist
ResourceNotExistError: 资源不存在;或 workspace_name 在该账号下未找到
/ Resource does not exist, or no workspace matches workspace_name
HTTPError: HTTP 请求错误 / HTTP request error
"""
if input.workspace_id and input.workspace_name:
raise ValueError(
"workspace_id and workspace_name are mutually exclusive; please"
" only set one of them."
)
if input.workspace_name is not None:
cfg = Config.with_configs(self.config, config)
resolved_id = await resolve_workspace_id_by_name_async(
input.workspace_name, cfg
)
input = input.model_copy(
update={"workspace_id": resolved_id, "workspace_name": None}
)

if input.network_configuration is None:
input.network_configuration = NetworkConfig()

Expand Down Expand Up @@ -198,12 +222,47 @@ async def list_async(
List[AgentRuntime]: Agent Runtime 对象列表 / List of Agent Runtime objects

Raises:
ValueError: 同时传入 workspace_id 与 workspace_name,或同时传入
workspace_ids 与 workspace_names / When workspace_id and
workspace_name (or workspace_ids and workspace_names) are
both set
ResourceNotExistError: workspace_name(s) 在该账号下未找到
/ No workspace matches the given workspace_name(s)
HTTPError: HTTP 请求错误 / HTTP request error
"""
try:
if input is None:
input = AgentRuntimeListInput()

if input.workspace_id and input.workspace_name:
raise ValueError(
"workspace_id and workspace_name are mutually exclusive;"
" please only set one of them."
)
if input.workspace_ids and input.workspace_names:
raise ValueError(
"workspace_ids and workspace_names are mutually exclusive;"
" please only set one of them."
)
if input.workspace_name is not None or input.workspace_names:
cfg = Config.with_configs(self.config, config)
update: dict = {}
if input.workspace_name is not None:
update["workspace_id"] = (
await resolve_workspace_id_by_name_async(
input.workspace_name, cfg
)
)
update["workspace_name"] = None
if input.workspace_names:
update["workspace_ids"] = (
await resolve_workspace_ids_by_names_async(
input.workspace_names, cfg
)
)
update["workspace_names"] = None
input = input.model_copy(update=update)

results = await self.__control_api.list_agent_runtimes_async(
ListAgentRuntimesRequest().from_map(input.model_dump()),
config=config,
Expand Down
22 changes: 18 additions & 4 deletions agentrun/agent_runtime/__runtime_async_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ async def create_async(
ResourceAlreadyExistError: 资源已存在 / Resource already exists
HTTPError: HTTP 请求错误 / HTTP request error
"""
return await cls.__get_client(config=config).create_async(input, config=config)
return await cls.__get_client(config=config).create_async(
input, config=config
)

@classmethod
async def delete_by_id_async(cls, id: str, config: Optional[Config] = None):
Expand Down Expand Up @@ -155,7 +157,9 @@ async def get_by_id_async(cls, id: str, config: Optional[Config] = None):
ResourceNotExistError: 资源不存在 / Resource does not exist
HTTPError: HTTP 请求错误 / HTTP request error
"""
return await cls.__get_client(config=config).get_async(id, config=config)
return await cls.__get_client(config=config).get_async(
id, config=config
)

@classmethod
async def _list_page_async(
Expand All @@ -174,16 +178,26 @@ async def list_all_async(
cls,
*,
agent_runtime_name: Optional[str] = None,
tags: Optional[str] = None,
system_tags: Optional[str] = None,
search_mode: Optional[str] = None,
status: Optional[str] = None,
workspace_id: Optional[str] = None,
workspace_ids: Optional[str] = None,
workspace_name: Optional[str] = None,
workspace_names: Optional[str] = None,
config: Optional[Config] = None,
) -> List["AgentRuntime"]:
return await cls._list_all_async(
lambda ar: ar.agent_runtime_id or "",
config=config,
agent_runtime_name=agent_runtime_name,
tags=tags,
system_tags=system_tags,
search_mode=search_mode,
status=status,
workspace_id=workspace_id,
workspace_ids=workspace_ids,
workspace_name=workspace_name,
workspace_names=workspace_names,
)

@classmethod
Expand Down
246 changes: 246 additions & 0 deletions agentrun/agent_runtime/_workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
"""Workspace 名称解析助手 / Workspace Name Resolution Helper

提供 ``workspace_name -> workspace_id`` 的解析能力,供 ``AgentRuntimeClient``
在 ``create`` / ``list`` 等场景下自动转换用户传入的工作空间名称。

The official AgentRun API only accepts ``workspace_id``. The SDK exposes a
convenience field ``workspace_name``; this module wraps ``list_workspaces``
to look the id up by name (exact match, with a simple in-memory cache).
"""

from typing import Dict, List, Optional, Tuple

from alibabacloud_agentrun20250910.models import (
ListWorkspacesRequest,
Workspace,
)
from alibabacloud_tea_openapi.exceptions._client import ClientException
from alibabacloud_tea_openapi.exceptions._server import ServerException
import pydash

from agentrun.utils.config import Config
from agentrun.utils.control_api import ControlAPI
from agentrun.utils.exception import (
ClientError,
ResourceNotExistError,
ServerError,
)

# Cache key 为 (access_key_id, region_id, name),避免不同账号/地域串号。
# Value 为解析得到的 workspace_id。
_RESOLVE_CACHE: Dict[Tuple[str, str, str], str] = {}

# 翻页参数:ListWorkspaces 的 name= 参数在服务端可能是 prefix/fuzzy 匹配,
# 单页 50 条不足以覆盖海量同前缀场景,因此累积所有页再做 exact match。
_PAGE_SIZE = 50
# 安全上限:避免上游异常导致死循环;20 页 × 50 条 = 1000 个候选,
# 同名 / 同前缀 workspace 远超该值的概率极低。
_MAX_PAGES = 20


def _cache_key(cfg: Config, name: str) -> Tuple[str, str, str]:
return (
cfg.get_access_key_id() or "",
cfg.get_region_id() or "",
name,
)


def _pick_exact_match(
workspaces: List[Workspace], name: str
) -> Optional[Workspace]:
matches = [w for w in workspaces if w.name == name]
if len(matches) > 1:
raise ValueError(
f"Workspace name {name!r} is ambiguous: matched"
f" {len(matches)} workspaces; please use workspace_id instead."
)
return matches[0] if matches else None


def _raise_for_tea_exception(e: Exception) -> None:
if isinstance(e, ClientException):
raise ClientError(
e.status_code,
pydash.get(e, "data.message", pydash.get(e, "message", "")),
pydash.get(e, "data.requestId", ""),
pydash.get(e, "data.code", ""),
) from e
if isinstance(e, ServerException):
raise ServerError(
e.status_code,
pydash.get(e, "data.message", pydash.get(e, "message", "")),
pydash.get(e, "data.requestId", ""),
pydash.get(e, "data.code", ""),
) from e


class _WorkspaceResolver(ControlAPI):
"""轻量封装:复用 ControlAPI 拿底层 AgentRun client。"""

def resolve(self, name: str, config: Optional[Config] = None) -> str:
if not name:
raise ValueError("workspace_name must be non-empty")

cfg = Config.with_configs(self.config, config)
cache_key = _cache_key(cfg, name)
if cache_key in _RESOLVE_CACHE:
return _RESOLVE_CACHE[cache_key]

ws = self._lookup_sync(name, config)
if ws is None:
raise ResourceNotExistError("Workspace", name)

assert ws.workspace_id is not None
_RESOLVE_CACHE[cache_key] = ws.workspace_id
return ws.workspace_id

async def resolve_async(
self, name: str, config: Optional[Config] = None
) -> str:
if not name:
raise ValueError("workspace_name must be non-empty")

cfg = Config.with_configs(self.config, config)
cache_key = _cache_key(cfg, name)
if cache_key in _RESOLVE_CACHE:
return _RESOLVE_CACHE[cache_key]

ws = await self._lookup_async(name, config)
if ws is None:
raise ResourceNotExistError("Workspace", name)

assert ws.workspace_id is not None
_RESOLVE_CACHE[cache_key] = ws.workspace_id
return ws.workspace_id

# --- internal -----------------------------------------------------------

def _lookup_sync(
self, name: str, config: Optional[Config] = None
) -> Optional[Workspace]:
client = self._get_client(config)
accumulated: List[Workspace] = []
page_number = 1
try:
while page_number <= _MAX_PAGES:
response = client.list_workspaces(
ListWorkspacesRequest(
name=name,
page_size=str(_PAGE_SIZE),
page_number=str(page_number),
)
)
workspaces = (
getattr(
getattr(response.body, "data", None),
"workspaces",
None,
)
or []
)
if not workspaces:
break
accumulated.extend(workspaces)
if len(workspaces) < _PAGE_SIZE:
break
page_number += 1
except (ClientException, ServerException) as e:
_raise_for_tea_exception(e)
raise
return _pick_exact_match(accumulated, name)

async def _lookup_async(
self, name: str, config: Optional[Config] = None
) -> Optional[Workspace]:
client = self._get_client(config)
accumulated: List[Workspace] = []
page_number = 1
try:
while page_number <= _MAX_PAGES:
response = await client.list_workspaces_async(
ListWorkspacesRequest(
name=name,
page_size=str(_PAGE_SIZE),
page_number=str(page_number),
)
)
workspaces = (
getattr(
getattr(response.body, "data", None),
"workspaces",
None,
)
or []
)
if not workspaces:
break
accumulated.extend(workspaces)
if len(workspaces) < _PAGE_SIZE:
break
page_number += 1
except (ClientException, ServerException) as e:
_raise_for_tea_exception(e)
raise
return _pick_exact_match(accumulated, name)


def resolve_workspace_id_by_name(
name: str, config: Optional[Config] = None
) -> str:
"""同步:根据 workspace name 解析出 workspace_id。

Raises:
ValueError: ``name`` 为空,或在该账号下存在重名 workspace。
ResourceNotExistError: 该账号下未找到同名 workspace。
"""

return _WorkspaceResolver(config).resolve(name, config)


async def resolve_workspace_id_by_name_async(
name: str, config: Optional[Config] = None
) -> str:
"""异步:根据 workspace name 解析出 workspace_id。"""

return await _WorkspaceResolver(config).resolve_async(name, config)


def resolve_workspace_ids_by_names(
names: str, config: Optional[Config] = None
) -> str:
"""同步:将逗号分隔的多个 workspace 名称解析为逗号分隔的 workspace_id 列表。"""

return ",".join(
resolve_workspace_id_by_name(n.strip(), config)
for n in names.split(",")
if n.strip()
)


async def resolve_workspace_ids_by_names_async(
names: str, config: Optional[Config] = None
) -> str:
"""异步:将逗号分隔的多个 workspace 名称解析为逗号分隔的 workspace_id 列表。"""

out: List[str] = []
for n in names.split(","):
n = n.strip()
if not n:
continue
out.append(await resolve_workspace_id_by_name_async(n, config))
return ",".join(out)


def _clear_cache_for_tests() -> None:
"""仅供单测使用:清空内部解析缓存。"""

_RESOLVE_CACHE.clear()


__all__ = [
"resolve_workspace_id_by_name",
"resolve_workspace_id_by_name_async",
"resolve_workspace_ids_by_names",
"resolve_workspace_ids_by_names_async",
]
Loading
Loading