From 42e0ad73716d2cf3fde9a613deeed0d176190003 Mon Sep 17 00:00:00 2001 From: Sodawyx Date: Thu, 14 May 2026 10:49:21 +0800 Subject: [PATCH 1/4] feat(agent_runtime): align SDK model with official agentrun-20250910 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 AgentRuntime / Endpoint / List 入参与官方 SDK 的字段差异,并把 ``tags`` 字段下线(已被原生 ``system_tags`` 覆盖,与 super_agent 模块统一)。 字段变更: - AgentRuntimeMutableProps: + disk_size, enable_session_isolation, nas_config, oss_mount_config; - tags - AgentRuntimeListInput: + status, workspace_ids; - tags - AgentRuntimeEndpointMutableProps: + disable_public_network_access, scaling_config; - tags - AgentRuntimeEndpointUpdateInput: + delete_scaling_config - AgentRuntimeContainer: + acr_instance_id, image_registry_type, port, registry_config - AgentRuntimeProtocolConfig: + protocol_settings - AgentRuntimeEndpointRoutingWeight.weight: int -> float 新增辅助模型(一比一对齐官方): - NASConfig / NASMountConfig - OSSMountConfig / OSSMountPoint - ScalingConfig / ScheduledPolicy - RegistryConfig / RegistryAuthConfig / RegistryCertConfig / RegistryNetworkConfig - ProtocolSettings list_all / list_all_async 形参同步更新:删 tags,加 system_tags / status / workspace_id / workspace_ids;runtime.py 通过 make codegen 重新生成。 测试:agent_runtime model.py 100% 行/分支覆盖;3444 全量单测通过; mypy --config-file mypy.ini agentrun/agent_runtime/ 无 issue。 Signed-off-by: Sodawyx --- .../agent_runtime/__runtime_async_template.py | 10 +- agentrun/agent_runtime/model.py | 201 ++++++++++++- agentrun/agent_runtime/runtime.py | 20 +- tests/unittests/agent_runtime/test_model.py | 284 +++++++++++++++++- tests/unittests/agent_runtime/test_runtime.py | 4 +- 5 files changed, 499 insertions(+), 20 deletions(-) diff --git a/agentrun/agent_runtime/__runtime_async_template.py b/agentrun/agent_runtime/__runtime_async_template.py index c1e70ef..270bb6d 100644 --- a/agentrun/agent_runtime/__runtime_async_template.py +++ b/agentrun/agent_runtime/__runtime_async_template.py @@ -174,16 +174,22 @@ 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, 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, ) @classmethod diff --git a/agentrun/agent_runtime/model.py b/agentrun/agent_runtime/model.py index ec728f8..4cb122c 100644 --- a/agentrun/agent_runtime/model.py +++ b/agentrun/agent_runtime/model.py @@ -139,13 +139,63 @@ def from_file( return c +class RegistryAuthConfig(BaseModel): + """镜像仓库认证配置 / Registry Authentication Configuration""" + + password: Optional[str] = None + """镜像仓库的登录密码 / Registry login password""" + user_name: Optional[str] = None + """镜像仓库的登录用户名 / Registry login username""" + + +class RegistryCertConfig(BaseModel): + """镜像仓库证书配置 / Registry Certificate Configuration""" + + insecure: Optional[bool] = None + """是否跳过 TLS 证书验证 / Whether to skip TLS certificate verification""" + root_ca_cert_base_64: Optional[str] = None + """镜像仓库根 CA 证书 Base64 编码 / Registry root CA certificate (Base64 encoded)""" + + +class RegistryNetworkConfig(BaseModel): + """镜像仓库网络配置 / Registry Network Configuration""" + + security_group_id: Optional[str] = None + """镜像仓库的安全组 ID / Registry security group ID""" + v_switch_id: Optional[str] = None + """镜像仓库所在的交换机 ID / Registry vSwitch ID""" + vpc_id: Optional[str] = None + """镜像仓库所在的 VPC ID / Registry VPC ID""" + + +class RegistryConfig(BaseModel): + """自定义镜像仓库配置 / Custom Registry Configuration""" + + auth_config: Optional[RegistryAuthConfig] = None + """镜像仓库的认证配置 / Registry authentication configuration""" + cert_config: Optional[RegistryCertConfig] = None + """镜像仓库的证书配置 / Registry certificate configuration""" + network_config: Optional[RegistryNetworkConfig] = None + """镜像仓库的网络配置 / Registry network configuration""" + + class AgentRuntimeContainer(BaseModel): """Agent Runtime 容器配置""" + acr_instance_id: Optional[str] = None + """阿里云容器镜像服务(ACR)的实例 ID / Aliyun Container Registry (ACR) instance ID""" command: Optional[List[str]] = Field(alias="command", default=None) """在运行时中运行的命令(例如:["python"])""" image: Optional[str] = Field(alias="image", default=None) """容器镜像地址""" + image_registry_type: Optional[str] = None + """容器镜像来源类型,支持 ACR / ACREE / CUSTOM + / Container image registry type: ACR / ACREE / CUSTOM""" + port: Optional[int] = None + """容器内部监听端口 / Container internal port""" + registry_config: Optional[RegistryConfig] = None + """自定义镜像仓库配置(当 image_registry_type 为 CUSTOM 时使用) + / Custom registry configuration (used when image_registry_type is CUSTOM)""" class AgentRuntimeHealthCheckConfig(BaseModel): @@ -188,20 +238,132 @@ class AgentRuntimeProtocolType(str, Enum): SUPER_AGENT = "SUPER_AGENT" +class ProtocolSettings(BaseModel): + """详细协议配置项 / Detailed Protocol Settings + + 用于配置单个协议的明细参数(路径、HTTP 方法、请求/响应 schema 等)。 + Used to configure detailed parameters for a single protocol (path, HTTP method, + request/response schemas, etc.). + """ + + a_2aagent_card: Optional[str] = Field(alias="A2AAgentCard", default=None) + """A2A Agent Card(兼容旧字段名 A2AAgentCard)/ A2A Agent Card (legacy field name)""" + a_2a_agent_card: Optional[str] = Field(alias="a2aAgentCard", default=None) + """A2A Agent Card / A2A Agent Card""" + a_2a_agent_card_url: Optional[str] = Field( + alias="a2aAgentCardUrl", default=None + ) + """A2A Agent Card URL / A2A Agent Card URL""" + config: Optional[str] = None + """协议配置的 JSON 字符串 / Protocol configuration JSON string""" + headers: Optional[str] = None + """请求头 / Request headers""" + input_body_json_schema: Optional[str] = None + """请求体 JSON Schema / Request body JSON schema""" + method: Optional[str] = None + """HTTP 方法 / HTTP method""" + name: Optional[str] = None + """可选展示名 / Optional display name""" + output_body_json_schema: Optional[str] = None + """响应体 JSON Schema / Response body JSON schema""" + path: Optional[str] = None + """协议路径 / Protocol path""" + path_prefix: Optional[str] = None + """协议路径前缀 / Protocol path prefix""" + request_content_type: Optional[str] = None + """请求内容类型 / Request content type""" + response_content_type: Optional[str] = None + """响应内容类型 / Response content type""" + type: Optional[str] = None + """协议类型标识 / Protocol type identifier""" + + class AgentRuntimeProtocolConfig(BaseModel): """Agent Runtime 协议配置""" + protocol_settings: Optional[List[ProtocolSettings]] = None + """详细的协议配置信息 / Detailed protocol settings""" type: AgentRuntimeProtocolType = Field( alias="type", default=AgentRuntimeProtocolType.HTTP ) """协议类型""" +class NASMountConfig(BaseModel): + """NAS 挂载点配置 / NAS Mount Configuration""" + + enable_tls: Optional[bool] = Field(alias="enableTLS", default=None) + """是否启用 TLS / Whether to enable TLS""" + mount_dir: Optional[str] = None + """挂载目录 / Mount directory""" + server_addr: Optional[str] = None + """NAS 服务地址 / NAS server address""" + + +class NASConfig(BaseModel): + """NAS 文件系统配置 / NAS Filesystem Configuration""" + + group_id: Optional[int] = None + """用户组 ID / Group ID""" + mount_points: Optional[List[NASMountConfig]] = None + """挂载点列表 / Mount points""" + user_id: Optional[int] = None + """用户 ID / User ID""" + + +class OSSMountPoint(BaseModel): + """OSS 挂载点 / OSS Mount Point""" + + bucket_name: Optional[str] = None + """OSS Bucket 名称 / OSS bucket name""" + bucket_path: Optional[str] = None + """OSS Bucket 中要挂载的路径 / Path inside the bucket to mount""" + endpoint: Optional[str] = None + """OSS Endpoint / OSS endpoint""" + mount_dir: Optional[str] = None + """容器内挂载目录 / Mount directory inside the container""" + read_only: Optional[bool] = None + """是否只读挂载 / Whether to mount read-only""" + + +class OSSMountConfig(BaseModel): + """OSS 挂载配置 / OSS Mount Configuration""" + + mount_points: Optional[List[OSSMountPoint]] = None + """挂载点列表 / Mount points""" + + +class ScheduledPolicy(BaseModel): + """端点弹性伸缩定时策略 / Endpoint Scaling Scheduled Policy""" + + end_time: Optional[str] = None + """结束时间 / End time""" + name: Optional[str] = None + """策略名称 / Policy name""" + schedule_expression: Optional[str] = None + """定时表达式(cron) / Schedule expression (cron)""" + start_time: Optional[str] = None + """开始时间 / Start time""" + target: Optional[int] = None + """目标实例数 / Target instance count""" + time_zone: Optional[str] = None + """时区 / Time zone""" + + +class ScalingConfig(BaseModel): + """端点弹性伸缩配置 / Endpoint Scaling Configuration""" + + min_instances: Optional[int] = None + """最小实例数 / Minimum instance count""" + scheduled_policies: Optional[List[ScheduledPolicy]] = None + """定时扩缩容策略列表 / Scheduled scaling policies""" + + class AgentRuntimeEndpointRoutingWeight(BaseModel): """智能体运行时端点路由配置""" version: Optional[str] = None - weight: Optional[int] = None + weight: Optional[float] = None class AgentRuntimeEndpointRoutingConfig(BaseModel): @@ -226,6 +388,12 @@ class AgentRuntimeMutableProps(BaseModel): """Agent Runtime 凭证 ID""" description: Optional[str] = None """Agent Runtime 描述""" + disk_size: Optional[int] = None + """Agent Runtime 实例磁盘大小,单位:GB + / Instance disk size in GB""" + enable_session_isolation: Optional[bool] = None + """是否启用会话隔离,启用后每个会话将在独立的环境中运行 + / Whether to enable session isolation; each session runs in an isolated environment when enabled""" environment_variables: Optional[Dict[str, str]] = None """环境变量""" execution_role_arn: Optional[str] = None @@ -238,8 +406,12 @@ class AgentRuntimeMutableProps(BaseModel): """日志配置""" memory: Optional[int] = 4096 """Agent Runtime 内存配置,单位:MB""" + nas_config: Optional[NASConfig] = None + """NAS 文件系统挂载配置 / NAS filesystem mount configuration""" network_configuration: Optional[NetworkConfig] = None """Agent Runtime 网络配置""" + oss_mount_config: Optional[OSSMountConfig] = None + """OSS 挂载配置 / OSS mount configuration""" port: Optional[int] = 9000 """Agent Runtime 端口配置""" protocol_configuration: Optional[AgentRuntimeProtocolConfig] = None @@ -250,10 +422,9 @@ class AgentRuntimeMutableProps(BaseModel): """每实例会话并发限制""" session_idle_timeout_seconds: Optional[int] = None """会话空闲超时时间,单位:秒""" - tags: Optional[List[str]] = None - """标签列表""" system_tags: Optional[List[str]] = None - """系统标签列表 (由平台内部使用, 例如 SuperAgent 用来标识下游 AgentRuntime)""" + """系统标签列表 (由平台内部使用, 例如 SuperAgent 用来标识下游 AgentRuntime) + / System tags (used internally by the platform, e.g. by SuperAgent to mark downstream AgentRuntimes)""" class AgentRuntimeImmutableProps(BaseModel): @@ -284,9 +455,14 @@ class AgentRuntimeSystemProps(BaseModel): class AgentRuntimeEndpointMutableProps(BaseModel): agent_runtime_endpoint_name: Optional[str] = None description: Optional[str] = None + disable_public_network_access: Optional[bool] = None + """是否禁用该端点的公网访问 + / Whether to disable public network access for this endpoint""" routing_configuration: Optional[AgentRuntimeEndpointRoutingConfig] = None """智能体运行时端点的路由配置,支持多版本权重分配""" - tags: Optional[List[str]] = None + scaling_config: Optional[ScalingConfig] = None + """端点的弹性伸缩配置,包括最小实例数和定时扩容策略 + / Endpoint scaling configuration: min instances and scheduled policies""" target_version: Optional[str] = "LATEST" """智能体运行时的目标版本""" @@ -325,15 +501,20 @@ class AgentRuntimeUpdateInput(AgentRuntimeMutableProps): class AgentRuntimeListInput(PageableInput): agent_runtime_name: Optional[str] = None """Agent Runtime 名称""" - tags: Optional[str] = None - """标签过滤,多个标签用逗号分隔""" system_tags: Optional[str] = None - """系统标签过滤, 多个标签用逗号分隔""" + """系统标签过滤, 多个标签用逗号分隔 + / Filter by system tags, comma separated""" search_mode: Optional[str] = None """搜索模式""" + status: Optional[str] = None + """按状态过滤,多个状态用逗号分隔,支持精确匹配 + / Filter by status, comma separated""" workspace_id: Optional[str] = None """按工作空间标识符过滤 / Filter by workspace identifier""" + workspace_ids: Optional[str] = None + """按多个工作空间标识符过滤,逗号分隔 + / Filter by multiple workspace identifiers, comma separated""" class AgentRuntimeEndpointCreateInput( @@ -343,7 +524,9 @@ class AgentRuntimeEndpointCreateInput( class AgentRuntimeEndpointUpdateInput(AgentRuntimeEndpointMutableProps): - pass + delete_scaling_config: Optional[bool] = None + """为 true 时删除该端点的弹性伸缩配置 + / If true, delete the existing scaling configuration for this endpoint""" class AgentRuntimeEndpointListInput(PageableInput): diff --git a/agentrun/agent_runtime/runtime.py b/agentrun/agent_runtime/runtime.py index a63dc29..1506f82 100644 --- a/agentrun/agent_runtime/runtime.py +++ b/agentrun/agent_runtime/runtime.py @@ -291,16 +291,22 @@ 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, 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, ) @classmethod @@ -308,16 +314,22 @@ def list_all( 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, config: Optional[Config] = None, ) -> List["AgentRuntime"]: return cls._list_all( 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, ) @classmethod diff --git a/tests/unittests/agent_runtime/test_model.py b/tests/unittests/agent_runtime/test_model.py index d1840fa..2cdb280 100644 --- a/tests/unittests/agent_runtime/test_model.py +++ b/tests/unittests/agent_runtime/test_model.py @@ -33,6 +33,17 @@ AgentRuntimeUpdateInput, AgentRuntimeVersion, AgentRuntimeVersionListInput, + NASConfig, + NASMountConfig, + OSSMountConfig, + OSSMountPoint, + ProtocolSettings, + RegistryAuthConfig, + RegistryCertConfig, + RegistryConfig, + RegistryNetworkConfig, + ScalingConfig, + ScheduledPolicy, ) from agentrun.utils.model import Status @@ -370,8 +381,9 @@ def test_init_empty(self): props = AgentRuntimeEndpointMutableProps() assert props.agent_runtime_endpoint_name is None assert props.description is None + assert props.disable_public_network_access is None assert props.routing_configuration is None - assert props.tags is None + assert props.scaling_config is None assert props.target_version == "LATEST" @@ -423,18 +435,25 @@ class TestAgentRuntimeListInput: def test_init_empty(self): input_obj = AgentRuntimeListInput() assert input_obj.agent_runtime_name is None - assert input_obj.tags is None + assert input_obj.system_tags is None assert input_obj.search_mode is None + assert input_obj.status is None + assert input_obj.workspace_id is None + assert input_obj.workspace_ids is None def test_init_with_values(self): input_obj = AgentRuntimeListInput( agent_runtime_name="test", - tags="env:prod,team:ai", + system_tags="env:prod,team:ai", search_mode="prefix", + status="READY", + workspace_ids="ws-1,ws-2", ) assert input_obj.agent_runtime_name == "test" - assert input_obj.tags == "env:prod,team:ai" + assert input_obj.system_tags == "env:prod,team:ai" assert input_obj.search_mode == "prefix" + assert input_obj.status == "READY" + assert input_obj.workspace_ids == "ws-1,ws-2" class TestAgentRuntimeEndpointCreateInput: @@ -527,3 +546,260 @@ def test_create_input_model_dump(self): assert dumped is not None # 验证值存在 assert "agentRuntimeName" in dumped or "agent_runtime_name" in dumped + + +class TestRegistryConfig: + """RegistryConfig 及其子模型测试""" + + def test_auth_config_round_trip(self): + cfg = RegistryAuthConfig(user_name="alice", password="pwd") + dumped = cfg.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"userName": "alice", "password": "pwd"} + + def test_cert_config_round_trip(self): + cfg = RegistryCertConfig(insecure=True, root_ca_cert_base_64="abc") + dumped = cfg.model_dump(by_alias=True, exclude_none=True) + assert dumped == { + "insecure": True, + "rootCaCertBase64": "abc", + } + + def test_network_config_round_trip(self): + cfg = RegistryNetworkConfig( + security_group_id="sg-1", + v_switch_id="vsw-1", + vpc_id="vpc-1", + ) + dumped = cfg.model_dump(by_alias=True, exclude_none=True) + assert dumped == { + "securityGroupId": "sg-1", + "vSwitchId": "vsw-1", + "vpcId": "vpc-1", + } + + def test_registry_config_nested(self): + cfg = RegistryConfig( + auth_config=RegistryAuthConfig(user_name="u", password="p"), + cert_config=RegistryCertConfig(insecure=False), + network_config=RegistryNetworkConfig(vpc_id="vpc-1"), + ) + dumped = cfg.model_dump(by_alias=True, exclude_none=True) + assert dumped["authConfig"]["userName"] == "u" + assert dumped["certConfig"]["insecure"] is False + assert dumped["networkConfig"]["vpcId"] == "vpc-1" + + +class TestContainerNewFields: + """AgentRuntimeContainer 新增字段测试""" + + def test_acr_and_registry_fields(self): + c = AgentRuntimeContainer( + image="img:1", + command=["python"], + acr_instance_id="cri-xxx", + image_registry_type="CUSTOM", + port=9001, + registry_config=RegistryConfig( + auth_config=RegistryAuthConfig(user_name="u", password="p") + ), + ) + dumped = c.model_dump(by_alias=True, exclude_none=True) + assert dumped["acrInstanceId"] == "cri-xxx" + assert dumped["imageRegistryType"] == "CUSTOM" + assert dumped["port"] == 9001 + assert dumped["registryConfig"]["authConfig"]["userName"] == "u" + + +class TestProtocolSettings: + """ProtocolSettings & AgentRuntimeProtocolConfig.protocol_settings 测试""" + + def test_protocol_settings_aliases(self): + ps = ProtocolSettings( + a_2aagent_card="legacy", + a_2a_agent_card="new", + a_2a_agent_card_url="https://example.com/card", + config='{"k":"v"}', + method="POST", + path="/invoke", + path_prefix="/api", + request_content_type="application/json", + response_content_type="application/json", + type="http", + ) + dumped = ps.model_dump(by_alias=True, exclude_none=True) + assert dumped["A2AAgentCard"] == "legacy" + assert dumped["a2aAgentCard"] == "new" + assert dumped["a2aAgentCardUrl"] == "https://example.com/card" + assert dumped["pathPrefix"] == "/api" + assert dumped["requestContentType"] == "application/json" + assert dumped["responseContentType"] == "application/json" + + def test_protocol_config_with_settings(self): + cfg = AgentRuntimeProtocolConfig( + type=AgentRuntimeProtocolType.HTTP, + protocol_settings=[ + ProtocolSettings(name="s1", type="http"), + ProtocolSettings(name="s2", type="grpc"), + ], + ) + dumped = cfg.model_dump(by_alias=True, exclude_none=True) + assert len(dumped["protocolSettings"]) == 2 + assert dumped["protocolSettings"][0]["name"] == "s1" + + +class TestRoutingWeightFloat: + """AgentRuntimeEndpointRoutingWeight.weight 改为 float""" + + def test_weight_accepts_float(self): + w = AgentRuntimeEndpointRoutingWeight(version="v1", weight=0.3) + assert w.weight == pytest.approx(0.3) + + def test_routing_config_serialization(self): + cfg = AgentRuntimeEndpointRoutingConfig( + version_weights=[ + AgentRuntimeEndpointRoutingWeight(version="v1", weight=0.7), + AgentRuntimeEndpointRoutingWeight(version="v2", weight=0.3), + ] + ) + dumped = cfg.model_dump(by_alias=True, exclude_none=True) + assert dumped["versionWeights"][0]["weight"] == pytest.approx(0.7) + assert dumped["versionWeights"][1]["weight"] == pytest.approx(0.3) + + +class TestNASAndOSSMountConfigs: + """NASConfig / OSSMountConfig 及其子模型测试""" + + def test_nas_mount_config_round_trip(self): + m = NASMountConfig( + enable_tls=True, + mount_dir="/mnt/nas", + server_addr="addr.cn-hangzhou.nas.aliyuncs.com", + ) + dumped = m.model_dump(by_alias=True, exclude_none=True) + assert dumped == { + "enableTLS": True, + "mountDir": "/mnt/nas", + "serverAddr": "addr.cn-hangzhou.nas.aliyuncs.com", + } + + def test_nas_config_with_mount_points(self): + cfg = NASConfig( + group_id=100, + user_id=200, + mount_points=[ + NASMountConfig( + mount_dir="/mnt/a", server_addr="addr.aliyuncs.com" + ), + ], + ) + dumped = cfg.model_dump(by_alias=True, exclude_none=True) + assert dumped["groupId"] == 100 + assert dumped["userId"] == 200 + assert dumped["mountPoints"][0]["mountDir"] == "/mnt/a" + + def test_oss_mount_point_round_trip(self): + p = OSSMountPoint( + bucket_name="bkt", + bucket_path="/path", + endpoint="oss-cn-hangzhou.aliyuncs.com", + mount_dir="/mnt/oss", + read_only=True, + ) + dumped = p.model_dump(by_alias=True, exclude_none=True) + assert dumped == { + "bucketName": "bkt", + "bucketPath": "/path", + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "mountDir": "/mnt/oss", + "readOnly": True, + } + + def test_oss_mount_config_with_points(self): + cfg = OSSMountConfig( + mount_points=[ + OSSMountPoint(bucket_name="bkt", mount_dir="/mnt"), + ] + ) + dumped = cfg.model_dump(by_alias=True, exclude_none=True) + assert dumped["mountPoints"][0]["bucketName"] == "bkt" + + +class TestScalingConfig: + """ScalingConfig & ScheduledPolicy 测试""" + + def test_scheduled_policy_round_trip(self): + sp = ScheduledPolicy( + name="daily", + schedule_expression="0 0 9 * * ?", + start_time="2026-05-01T00:00:00Z", + end_time="2026-06-01T00:00:00Z", + target=3, + time_zone="Asia/Shanghai", + ) + dumped = sp.model_dump(by_alias=True, exclude_none=True) + assert dumped["scheduleExpression"] == "0 0 9 * * ?" + assert dumped["startTime"] == "2026-05-01T00:00:00Z" + assert dumped["endTime"] == "2026-06-01T00:00:00Z" + assert dumped["timeZone"] == "Asia/Shanghai" + assert dumped["target"] == 3 + + def test_scaling_config_with_policies(self): + cfg = ScalingConfig( + min_instances=2, + scheduled_policies=[ + ScheduledPolicy(name="p1", target=5), + ], + ) + dumped = cfg.model_dump(by_alias=True, exclude_none=True) + assert dumped["minInstances"] == 2 + assert dumped["scheduledPolicies"][0]["name"] == "p1" + + +class TestEndpointNewFields: + """AgentRuntimeEndpoint Create/Update 新增字段测试""" + + def test_create_input_with_disable_public_and_scaling(self): + i = AgentRuntimeEndpointCreateInput( + agent_runtime_endpoint_name="ep", + disable_public_network_access=True, + scaling_config=ScalingConfig(min_instances=1), + ) + dumped = i.model_dump(by_alias=True, exclude_none=True) + assert dumped["disablePublicNetworkAccess"] is True + assert dumped["scalingConfig"]["minInstances"] == 1 + + def test_update_input_delete_scaling_config(self): + i = AgentRuntimeEndpointUpdateInput( + agent_runtime_endpoint_name="ep", + delete_scaling_config=True, + ) + dumped = i.model_dump(by_alias=True, exclude_none=True) + assert dumped["deleteScalingConfig"] is True + + +class TestRuntimeNewFields: + """AgentRuntimeMutableProps 新增字段测试""" + + def test_disk_size_and_session_isolation(self): + m = AgentRuntimeMutableProps( + disk_size=30, + enable_session_isolation=True, + ) + dumped = m.model_dump(by_alias=True, exclude_none=True) + assert dumped["diskSize"] == 30 + assert dumped["enableSessionIsolation"] is True + + def test_nas_and_oss_mount_in_create_input(self): + i = AgentRuntimeCreateInput( + agent_runtime_name="r", + nas_config=NASConfig( + user_id=1, mount_points=[NASMountConfig(mount_dir="/mnt/nas")] + ), + oss_mount_config=OSSMountConfig( + mount_points=[OSSMountPoint(bucket_name="b", mount_dir="/mnt")] + ), + ) + dumped = i.model_dump(by_alias=True, exclude_none=True) + assert dumped["nasConfig"]["userId"] == 1 + assert dumped["nasConfig"]["mountPoints"][0]["mountDir"] == "/mnt/nas" + assert dumped["ossMountConfig"]["mountPoints"][0]["bucketName"] == "b" diff --git a/tests/unittests/agent_runtime/test_runtime.py b/tests/unittests/agent_runtime/test_runtime.py index 73ec836..9c08177 100644 --- a/tests/unittests/agent_runtime/test_runtime.py +++ b/tests/unittests/agent_runtime/test_runtime.py @@ -521,8 +521,10 @@ def test_list_all_with_filters(self, mock_client_class): result = AgentRuntime.list_all( agent_runtime_name="test", - tags="env:prod", + system_tags="env:prod", search_mode="prefix", + status="READY", + workspace_ids="ws-1,ws-2", ) assert len(result) >= 1 From 6cc91e1025a8b5ff221921e5b591210884a5c02e Mon Sep 17 00:00:00 2001 From: Sodawyx Date: Tue, 19 May 2026 15:50:24 +0800 Subject: [PATCH 2/4] feat(agent_runtime): support workspace_name in create/list (auto-resolve to workspace_id) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让用户在创建 / 查询 Agent Runtime 时可以直接填 workspace 名称, SDK 自动调用官方 ListWorkspaces 解析为 workspace_id 再下发, 无需用户手动查 ID。 模型变更(agentrun/agent_runtime/model.py): - AgentRuntimeImmutableProps: 新增 workspace_name(流入 CreateInput) - AgentRuntimeListInput: 新增 workspace_name / workspace_names 新增 agentrun/agent_runtime/_workspace.py: - resolve_workspace_id_by_name(_async) 精确名字匹配 + (ak, region, name) 缓存 - resolve_workspace_ids_by_names(_async) 批量名字 -> 逗号分隔 ID - 找不到抛 ResourceNotExistError,重名抛 ValueError, Tea ClientException/ServerException 转 SDK 内置 ClientError/ServerError client / runtime(async 模板 + codegen 同步生成): - AgentRuntimeClient.create / list:调底层 API 前自动解析 workspace_name(s) - 同时传 workspace_id+workspace_name(或复数版本)抛 ValueError - AgentRuntime.list_all:透传 workspace_name / workspace_names 示例:examples/quickstart_runtime.py - 演示通过镜像部署 AgentRuntime,并使用 workspace_name 选择工作空间 测试(tests/unittests/agent_runtime/test_workspace.py,新增 25 用例): - 精确匹配 / 缓存 / 空名 / 找不到 / 重名 / Tea 异常透传 - client.create 与 client.list 在 sync + async 路径下的解析与互斥校验 校验: - 全量 3469 单测通过 - agentrun.agent_runtime 总覆盖率 99%,_workspace.py 95% - mypy --config-file mypy.ini agentrun/agent_runtime/ 无 issue Signed-off-by: Sodawyx --- .../agent_runtime/__client_async_template.py | 54 ++- .../agent_runtime/__runtime_async_template.py | 12 +- agentrun/agent_runtime/_workspace.py | 205 ++++++++ agentrun/agent_runtime/client.py | 100 +++- agentrun/agent_runtime/model.py | 15 + agentrun/agent_runtime/runtime.py | 16 +- .../unittests/agent_runtime/test_workspace.py | 445 ++++++++++++++++++ 7 files changed, 837 insertions(+), 10 deletions(-) create mode 100644 agentrun/agent_runtime/_workspace.py create mode 100644 tests/unittests/agent_runtime/test_workspace.py diff --git a/agentrun/agent_runtime/__client_async_template.py b/agentrun/agent_runtime/__client_async_template.py index d72bac6..246507c 100644 --- a/agentrun/agent_runtime/__client_async_template.py +++ b/agentrun/agent_runtime/__client_async_template.py @@ -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, @@ -68,11 +74,26 @@ 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: + input.workspace_id = await resolve_workspace_id_by_name_async( + input.workspace_name, config + ) + input.workspace_name = None + if input.network_configuration is None: input.network_configuration = NetworkConfig() @@ -198,12 +219,41 @@ 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: + input.workspace_id = await resolve_workspace_id_by_name_async( + input.workspace_name, config + ) + input.workspace_name = None + if input.workspace_names: + input.workspace_ids = ( + await resolve_workspace_ids_by_names_async( + input.workspace_names, config + ) + ) + input.workspace_names = None + results = await self.__control_api.list_agent_runtimes_async( ListAgentRuntimesRequest().from_map(input.model_dump()), config=config, diff --git a/agentrun/agent_runtime/__runtime_async_template.py b/agentrun/agent_runtime/__runtime_async_template.py index 270bb6d..c0d8b31 100644 --- a/agentrun/agent_runtime/__runtime_async_template.py +++ b/agentrun/agent_runtime/__runtime_async_template.py @@ -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): @@ -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( @@ -179,6 +183,8 @@ async def list_all_async( 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( @@ -190,6 +196,8 @@ async def list_all_async( status=status, workspace_id=workspace_id, workspace_ids=workspace_ids, + workspace_name=workspace_name, + workspace_names=workspace_names, ) @classmethod diff --git a/agentrun/agent_runtime/_workspace.py b/agentrun/agent_runtime/_workspace.py new file mode 100644 index 0000000..cab07dd --- /dev/null +++ b/agentrun/agent_runtime/_workspace.py @@ -0,0 +1,205 @@ +"""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] = {} + + +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) + try: + response = client.list_workspaces( + ListWorkspacesRequest(name=name, page_size="50") + ) + except (ClientException, ServerException) as e: + _raise_for_tea_exception(e) + raise + workspaces = ( + getattr(getattr(response.body, "data", None), "workspaces", None) + or [] + ) + return _pick_exact_match(workspaces, name) + + async def _lookup_async( + self, name: str, config: Optional[Config] = None + ) -> Optional[Workspace]: + client = self._get_client(config) + try: + response = await client.list_workspaces_async( + ListWorkspacesRequest(name=name, page_size="50") + ) + except (ClientException, ServerException) as e: + _raise_for_tea_exception(e) + raise + workspaces = ( + getattr(getattr(response.body, "data", None), "workspaces", None) + or [] + ) + return _pick_exact_match(workspaces, 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", +] diff --git a/agentrun/agent_runtime/client.py b/agentrun/agent_runtime/client.py index 6010463..4b8ded3 100644 --- a/agentrun/agent_runtime/client.py +++ b/agentrun/agent_runtime/client.py @@ -28,6 +28,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, @@ -78,11 +84,26 @@ 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: + input.workspace_id = await resolve_workspace_id_by_name_async( + input.workspace_name, config + ) + input.workspace_name = None + if input.network_configuration is None: input.network_configuration = NetworkConfig() @@ -121,11 +142,26 @@ def create( 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: + input.workspace_id = resolve_workspace_id_by_name( + input.workspace_name, config + ) + input.workspace_name = None + if input.network_configuration is None: input.network_configuration = NetworkConfig() @@ -332,12 +368,41 @@ 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: + input.workspace_id = await resolve_workspace_id_by_name_async( + input.workspace_name, config + ) + input.workspace_name = None + if input.workspace_names: + input.workspace_ids = ( + await resolve_workspace_ids_by_names_async( + input.workspace_names, config + ) + ) + input.workspace_names = None + results = await self.__control_api.list_agent_runtimes_async( ListAgentRuntimesRequest().from_map(input.model_dump()), config=config, @@ -363,12 +428,39 @@ def list( 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: + input.workspace_id = resolve_workspace_id_by_name( + input.workspace_name, config + ) + input.workspace_name = None + if input.workspace_names: + input.workspace_ids = resolve_workspace_ids_by_names( + input.workspace_names, config + ) + input.workspace_names = None + results = self.__control_api.list_agent_runtimes( ListAgentRuntimesRequest().from_map(input.model_dump()), config=config, diff --git a/agentrun/agent_runtime/model.py b/agentrun/agent_runtime/model.py index 4cb122c..0cdf476 100644 --- a/agentrun/agent_runtime/model.py +++ b/agentrun/agent_runtime/model.py @@ -431,6 +431,11 @@ class AgentRuntimeImmutableProps(BaseModel): workspace_id: Optional[str] = None """Agent Runtime 所属的工作空间标识符;可选项,不填则使用默认工作空间 / Workspace identifier the Agent Runtime belongs to; optional, defaults to the default workspace if not provided""" + workspace_name: Optional[str] = None + """Agent Runtime 所属的工作空间名称;SDK 会在创建时自动解析为 workspace_id。 + 与 workspace_id 二选一,同时传入会抛出 ValueError。 + / Workspace name the Agent Runtime belongs to; the SDK resolves it to + workspace_id on create. Mutually exclusive with workspace_id.""" class AgentRuntimeSystemProps(BaseModel): @@ -515,6 +520,16 @@ class AgentRuntimeListInput(PageableInput): workspace_ids: Optional[str] = None """按多个工作空间标识符过滤,逗号分隔 / Filter by multiple workspace identifiers, comma separated""" + workspace_name: Optional[str] = None + """按工作空间名称过滤;SDK 会在调用时自动解析为 workspace_id。 + 与 workspace_id 二选一,同时传入会抛出 ValueError。 + / Filter by workspace name; resolved to workspace_id by the SDK. + Mutually exclusive with workspace_id.""" + workspace_names: Optional[str] = None + """按多个工作空间名称过滤,逗号分隔;SDK 会逐个解析并填入 workspace_ids。 + 与 workspace_ids 二选一。 + / Filter by multiple workspace names (comma separated); resolved to + workspace_ids by the SDK. Mutually exclusive with workspace_ids.""" class AgentRuntimeEndpointCreateInput( diff --git a/agentrun/agent_runtime/runtime.py b/agentrun/agent_runtime/runtime.py index 1506f82..37ad89f 100644 --- a/agentrun/agent_runtime/runtime.py +++ b/agentrun/agent_runtime/runtime.py @@ -87,7 +87,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 def create( @@ -243,7 +245,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 def get_by_id(cls, id: str, config: Optional[Config] = None): @@ -296,6 +300,8 @@ async def list_all_async( 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( @@ -307,6 +313,8 @@ async def list_all_async( status=status, workspace_id=workspace_id, workspace_ids=workspace_ids, + workspace_name=workspace_name, + workspace_names=workspace_names, ) @classmethod @@ -319,6 +327,8 @@ def list_all( 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 cls._list_all( @@ -330,6 +340,8 @@ def list_all( status=status, workspace_id=workspace_id, workspace_ids=workspace_ids, + workspace_name=workspace_name, + workspace_names=workspace_names, ) @classmethod diff --git a/tests/unittests/agent_runtime/test_workspace.py b/tests/unittests/agent_runtime/test_workspace.py new file mode 100644 index 0000000..9ba9feb --- /dev/null +++ b/tests/unittests/agent_runtime/test_workspace.py @@ -0,0 +1,445 @@ +"""Workspace 名称解析 & 客户端集成单元测试""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from agentrun.agent_runtime import _workspace as ws_mod +from agentrun.agent_runtime._workspace import ( + _clear_cache_for_tests, + 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.model import ( + AgentRuntimeContainer, + AgentRuntimeCreateInput, + AgentRuntimeListInput, +) +from agentrun.utils.exception import ResourceNotExistError + +CONTROL_API_PATH = "agentrun.agent_runtime.client.AgentRuntimeControlAPI" + + +def _make_ws(name: str, ws_id: str): + ws = MagicMock() + ws.name = name + ws.workspace_id = ws_id + return ws + + +def _make_response(workspaces): + resp = MagicMock() + resp.body.data.workspaces = workspaces + return resp + + +@pytest.fixture(autouse=True) +def _clear_ws_cache(): + _clear_cache_for_tests() + yield + _clear_cache_for_tests() + + +class TestResolveWorkspace: + """resolve_workspace_id_by_name 行为测试""" + + def _patch_client(self, monkeypatch, workspaces): + client = MagicMock() + client.list_workspaces.return_value = _make_response(workspaces) + client.list_workspaces_async = AsyncMock( + return_value=_make_response(workspaces) + ) + monkeypatch.setattr( + ws_mod._WorkspaceResolver, + "_get_client", + lambda self, config=None: client, + ) + return client + + def test_resolve_sync_exact_match(self, monkeypatch): + self._patch_client( + monkeypatch, + [_make_ws("other", "ws-other"), _make_ws("my-ws", "ws-123")], + ) + assert resolve_workspace_id_by_name("my-ws") == "ws-123" + + def test_resolve_sync_uses_cache(self, monkeypatch): + client = self._patch_client(monkeypatch, [_make_ws("my-ws", "ws-123")]) + assert resolve_workspace_id_by_name("my-ws") == "ws-123" + assert resolve_workspace_id_by_name("my-ws") == "ws-123" + # 二次解析应命中缓存,避免重复 list_workspaces + assert client.list_workspaces.call_count == 1 + + def test_resolve_sync_not_found(self, monkeypatch): + self._patch_client(monkeypatch, []) + with pytest.raises(ResourceNotExistError): + resolve_workspace_id_by_name("absent") + + def test_resolve_sync_ambiguous(self, monkeypatch): + self._patch_client( + monkeypatch, + [_make_ws("dup", "ws-1"), _make_ws("dup", "ws-2")], + ) + with pytest.raises(ValueError, match="ambiguous"): + resolve_workspace_id_by_name("dup") + + def test_resolve_sync_empty_name(self): + with pytest.raises(ValueError, match="non-empty"): + resolve_workspace_id_by_name("") + + def test_resolve_sync_none_workspaces_response(self, monkeypatch): + # response.body.data.workspaces 为 None 时应正常报 not-exist + self._patch_client(monkeypatch, None) + with pytest.raises(ResourceNotExistError): + resolve_workspace_id_by_name("absent") + + def test_resolve_sync_client_exception_raises_client_error( + self, monkeypatch + ): + from alibabacloud_tea_openapi.exceptions._client import ClientException + + from agentrun.utils.exception import ClientError + + client = MagicMock() + client.list_workspaces.side_effect = ClientException( + status_code=403, data={"message": "denied"} + ) + monkeypatch.setattr( + ws_mod._WorkspaceResolver, + "_get_client", + lambda self, config=None: client, + ) + with pytest.raises(ClientError): + resolve_workspace_id_by_name("my-ws") + + def test_resolve_sync_server_exception_raises_server_error( + self, monkeypatch + ): + from alibabacloud_tea_openapi.exceptions._server import ServerException + + from agentrun.utils.exception import ServerError + + client = MagicMock() + client.list_workspaces.side_effect = ServerException( + status_code=500, data={"message": "boom"} + ) + monkeypatch.setattr( + ws_mod._WorkspaceResolver, + "_get_client", + lambda self, config=None: client, + ) + with pytest.raises(ServerError): + resolve_workspace_id_by_name("my-ws") + + def test_resolve_async_exact_match(self, monkeypatch): + self._patch_client(monkeypatch, [_make_ws("my-ws", "ws-async-1")]) + + result = asyncio.run(resolve_workspace_id_by_name_async("my-ws")) + assert result == "ws-async-1" + + def test_resolve_async_not_found(self, monkeypatch): + self._patch_client(monkeypatch, []) + with pytest.raises(ResourceNotExistError): + asyncio.run(resolve_workspace_id_by_name_async("absent")) + + def test_resolve_async_empty_name(self): + with pytest.raises(ValueError, match="non-empty"): + asyncio.run(resolve_workspace_id_by_name_async("")) + + def test_resolve_async_uses_cache(self, monkeypatch): + client = self._patch_client(monkeypatch, [_make_ws("my-ws", "ws-123")]) + + async def go(): + await resolve_workspace_id_by_name_async("my-ws") + await resolve_workspace_id_by_name_async("my-ws") + + asyncio.run(go()) + assert client.list_workspaces_async.await_count == 1 + + def test_resolve_async_ambiguous(self, monkeypatch): + self._patch_client( + monkeypatch, + [_make_ws("dup", "ws-1"), _make_ws("dup", "ws-2")], + ) + with pytest.raises(ValueError, match="ambiguous"): + asyncio.run(resolve_workspace_id_by_name_async("dup")) + + def test_resolve_async_client_exception(self, monkeypatch): + from alibabacloud_tea_openapi.exceptions._client import ClientException + + from agentrun.utils.exception import ClientError + + client = MagicMock() + client.list_workspaces_async = AsyncMock( + side_effect=ClientException( + status_code=403, data={"message": "denied"} + ) + ) + monkeypatch.setattr( + ws_mod._WorkspaceResolver, + "_get_client", + lambda self, config=None: client, + ) + with pytest.raises(ClientError): + asyncio.run(resolve_workspace_id_by_name_async("my-ws")) + + def test_resolve_async_server_exception(self, monkeypatch): + from alibabacloud_tea_openapi.exceptions._server import ServerException + + from agentrun.utils.exception import ServerError + + client = MagicMock() + client.list_workspaces_async = AsyncMock( + side_effect=ServerException( + status_code=500, data={"message": "boom"} + ) + ) + monkeypatch.setattr( + ws_mod._WorkspaceResolver, + "_get_client", + lambda self, config=None: client, + ) + with pytest.raises(ServerError): + asyncio.run(resolve_workspace_id_by_name_async("my-ws")) + + def test_resolve_many_sync(self, monkeypatch): + # 模拟两次连续调用返回不同的 workspace + client = MagicMock() + client.list_workspaces.side_effect = [ + _make_response([_make_ws("a", "ws-a")]), + _make_response([_make_ws("b", "ws-b")]), + ] + monkeypatch.setattr( + ws_mod._WorkspaceResolver, + "_get_client", + lambda self, config=None: client, + ) + assert resolve_workspace_ids_by_names("a, b") == "ws-a,ws-b" + + def test_resolve_many_sync_strips_empty(self, monkeypatch): + # 空字符串 / 仅有逗号空格 应被忽略 + self._patch_client(monkeypatch, [_make_ws("a", "ws-a")]) + assert resolve_workspace_ids_by_names("a, ,") == "ws-a" + + def test_resolve_many_async(self, monkeypatch): + client = MagicMock() + client.list_workspaces_async = AsyncMock( + side_effect=[ + _make_response([_make_ws("a", "ws-a")]), + _make_response([_make_ws("b", "ws-b")]), + ] + ) + monkeypatch.setattr( + ws_mod._WorkspaceResolver, + "_get_client", + lambda self, config=None: client, + ) + result = asyncio.run(resolve_workspace_ids_by_names_async("a,b")) + assert result == "ws-a,ws-b" + + +class TestClientCreateWorkspaceResolution: + """create 集成测试:workspace_name 自动转换为 workspace_id""" + + @patch(CONTROL_API_PATH) + @patch( + "agentrun.agent_runtime.client.resolve_workspace_id_by_name", + return_value="ws-resolved", + ) + def test_create_with_workspace_name_resolves( + self, mock_resolve, mock_control_api_class + ): + from agentrun.agent_runtime.client import AgentRuntimeClient + + # control_api 不在路径中暴露需要的真实 client,所以仅 mock 其 create 返回值 + mock_control_api = MagicMock() + mock_data = MagicMock() + mock_data.agent_runtime_id = "ar-1" + mock_data.to_map.return_value = { + "agentRuntimeId": "ar-1", + "agentRuntimeName": "n", + "status": "READY", + } + mock_control_api.create_agent_runtime.return_value = mock_data + mock_control_api_class.return_value = mock_control_api + + client = AgentRuntimeClient() + inp = AgentRuntimeCreateInput( + agent_runtime_name="n", + workspace_name="my-ws", + container_configuration=AgentRuntimeContainer( + image="img", port=9000 + ), + ) + client.create(inp) + + mock_resolve.assert_called_once_with("my-ws", None) + # 调用 API 前,应把 workspace_name 清空且 workspace_id 写好 + assert inp.workspace_id == "ws-resolved" + assert inp.workspace_name is None + + @patch(CONTROL_API_PATH) + def test_create_rejects_both_workspace_fields(self, mock_control_api_class): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_control_api_class.return_value = MagicMock() + client = AgentRuntimeClient() + inp = AgentRuntimeCreateInput( + agent_runtime_name="n", + workspace_id="ws-1", + workspace_name="my-ws", + container_configuration=AgentRuntimeContainer( + image="img", port=9000 + ), + ) + with pytest.raises(ValueError, match="mutually exclusive"): + client.create(inp) + + @patch(CONTROL_API_PATH) + @patch( + "agentrun.agent_runtime.client.resolve_workspace_id_by_name_async", + new_callable=AsyncMock, + ) + def test_create_async_resolves_workspace_name( + self, mock_resolve_async, mock_control_api_class + ): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_resolve_async.return_value = "ws-resolved-async" + mock_control_api = MagicMock() + mock_data = MagicMock() + mock_data.agent_runtime_id = "ar-1" + mock_data.to_map.return_value = { + "agentRuntimeId": "ar-1", + "agentRuntimeName": "n", + "status": "READY", + } + mock_control_api.create_agent_runtime_async = AsyncMock( + return_value=mock_data + ) + mock_control_api_class.return_value = mock_control_api + + client = AgentRuntimeClient() + inp = AgentRuntimeCreateInput( + agent_runtime_name="n", + workspace_name="my-ws", + container_configuration=AgentRuntimeContainer( + image="img", port=9000 + ), + ) + asyncio.run(client.create_async(inp)) + + mock_resolve_async.assert_awaited_once_with("my-ws", None) + assert inp.workspace_id == "ws-resolved-async" + assert inp.workspace_name is None + + +class TestClientListWorkspaceResolution: + """list 集成测试:workspace_name / workspace_names 自动转换""" + + @patch(CONTROL_API_PATH) + @patch( + "agentrun.agent_runtime.client.resolve_workspace_id_by_name", + return_value="ws-1", + ) + @patch( + "agentrun.agent_runtime.client.resolve_workspace_ids_by_names", + return_value="ws-1,ws-2", + ) + def test_list_resolves_workspace_name_and_names( + self, + mock_resolve_many, + mock_resolve_one, + mock_control_api_class, + ): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_control_api = MagicMock() + mock_result = MagicMock() + mock_result.items = [] + mock_control_api.list_agent_runtimes.return_value = mock_result + mock_control_api_class.return_value = mock_control_api + + client = AgentRuntimeClient() + inp = AgentRuntimeListInput( + workspace_name="my-ws", + workspace_names="ws-a,ws-b", + ) + client.list(inp) + + mock_resolve_one.assert_called_once_with("my-ws", None) + mock_resolve_many.assert_called_once_with("ws-a,ws-b", None) + assert inp.workspace_id == "ws-1" + assert inp.workspace_ids == "ws-1,ws-2" + assert inp.workspace_name is None + assert inp.workspace_names is None + + @patch(CONTROL_API_PATH) + def test_list_rejects_both_singular(self, mock_control_api_class): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_control_api_class.return_value = MagicMock() + client = AgentRuntimeClient() + inp = AgentRuntimeListInput( + workspace_id="ws-1", + workspace_name="my-ws", + ) + with pytest.raises(ValueError, match="workspace_id and workspace_name"): + client.list(inp) + + @patch(CONTROL_API_PATH) + def test_list_rejects_both_plural(self, mock_control_api_class): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_control_api_class.return_value = MagicMock() + client = AgentRuntimeClient() + inp = AgentRuntimeListInput( + workspace_ids="ws-1,ws-2", + workspace_names="a,b", + ) + with pytest.raises( + ValueError, match="workspace_ids and workspace_names" + ): + client.list(inp) + + @patch(CONTROL_API_PATH) + @patch( + "agentrun.agent_runtime.client.resolve_workspace_id_by_name_async", + new_callable=AsyncMock, + ) + @patch( + "agentrun.agent_runtime.client.resolve_workspace_ids_by_names_async", + new_callable=AsyncMock, + ) + def test_list_async_resolves( + self, + mock_resolve_many_async, + mock_resolve_one_async, + mock_control_api_class, + ): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_resolve_one_async.return_value = "ws-1" + mock_resolve_many_async.return_value = "ws-1,ws-2" + mock_control_api = MagicMock() + mock_result = MagicMock() + mock_result.items = [] + mock_control_api.list_agent_runtimes_async = AsyncMock( + return_value=mock_result + ) + mock_control_api_class.return_value = mock_control_api + + client = AgentRuntimeClient() + inp = AgentRuntimeListInput( + workspace_name="my-ws", + workspace_names="ws-a,ws-b", + ) + asyncio.run(client.list_async(inp)) + + mock_resolve_one_async.assert_awaited_once_with("my-ws", None) + mock_resolve_many_async.assert_awaited_once_with("ws-a,ws-b", None) + assert inp.workspace_id == "ws-1" + assert inp.workspace_ids == "ws-1,ws-2" From bbef5b9cbea0e8861d23252b5cfc8d42590453c3 Mon Sep 17 00:00:00 2001 From: Sodawyx Date: Wed, 20 May 2026 14:10:39 +0800 Subject: [PATCH 3/4] fix(agent_runtime): use effective config for workspace name resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #102 Copilot review surfaced two issues in the new workspace_name resolution path: 1. workspace_name resolution only used the method-level `config`, ignoring `self.config`. When a caller built `AgentRuntimeClient(config=...)` and then called `create(input)` without a method-level config, the resolver fell back to env credentials/region while the subsequent OpenAPI call used `self.config` — possibly resolving a workspace under the wrong account/region. Now passes `Config.with_configs(self.config, config)` to the resolver, matching the merge done in `ControlAPI._get_client`. 2. `if input.workspace_name:` silently skipped resolution for empty strings instead of surfacing the resolver's existing `ValueError("workspace_name must be non-empty")`. Switched the singular checks to `is not None` so empty strings now raise. Applied to create / create_async / list / list_async in client.py and mirrored into the codegen template. Plural `workspace_names` keeps the truthy check since empty-string plural is a benign "no filter". Added 8 unit tests covering effective-config propagation (client.config used when method config=None; method config overrides client.config) and empty-string ValueError across all four entry points. agent_runtime suite 262 passed / 2 skipped; `_workspace.py` 95.24% line/branch. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Sodawyx --- .../agent_runtime/__client_async_template.py | 31 ++- agentrun/agent_runtime/client.py | 58 ++-- .../unittests/agent_runtime/test_workspace.py | 257 +++++++++++++++++- 3 files changed, 302 insertions(+), 44 deletions(-) diff --git a/agentrun/agent_runtime/__client_async_template.py b/agentrun/agent_runtime/__client_async_template.py index 246507c..2c466d1 100644 --- a/agentrun/agent_runtime/__client_async_template.py +++ b/agentrun/agent_runtime/__client_async_template.py @@ -88,9 +88,10 @@ async def create_async( "workspace_id and workspace_name are mutually exclusive; please" " only set one of them." ) - if input.workspace_name: + if input.workspace_name is not None: + cfg = Config.with_configs(self.config, config) input.workspace_id = await resolve_workspace_id_by_name_async( - input.workspace_name, config + input.workspace_name, cfg ) input.workspace_name = None @@ -241,18 +242,22 @@ async def list_async( "workspace_ids and workspace_names are mutually exclusive;" " please only set one of them." ) - if input.workspace_name: - input.workspace_id = await resolve_workspace_id_by_name_async( - input.workspace_name, config - ) - input.workspace_name = None - if input.workspace_names: - input.workspace_ids = ( - await resolve_workspace_ids_by_names_async( - input.workspace_names, config + if input.workspace_name is not None or input.workspace_names: + cfg = Config.with_configs(self.config, config) + if input.workspace_name is not None: + input.workspace_id = ( + await resolve_workspace_id_by_name_async( + input.workspace_name, cfg + ) ) - ) - input.workspace_names = None + input.workspace_name = None + if input.workspace_names: + input.workspace_ids = ( + await resolve_workspace_ids_by_names_async( + input.workspace_names, cfg + ) + ) + input.workspace_names = None results = await self.__control_api.list_agent_runtimes_async( ListAgentRuntimesRequest().from_map(input.model_dump()), diff --git a/agentrun/agent_runtime/client.py b/agentrun/agent_runtime/client.py index 4b8ded3..5d09261 100644 --- a/agentrun/agent_runtime/client.py +++ b/agentrun/agent_runtime/client.py @@ -98,9 +98,10 @@ async def create_async( "workspace_id and workspace_name are mutually exclusive; please" " only set one of them." ) - if input.workspace_name: + if input.workspace_name is not None: + cfg = Config.with_configs(self.config, config) input.workspace_id = await resolve_workspace_id_by_name_async( - input.workspace_name, config + input.workspace_name, cfg ) input.workspace_name = None @@ -156,9 +157,10 @@ def create( "workspace_id and workspace_name are mutually exclusive; please" " only set one of them." ) - if input.workspace_name: + if input.workspace_name is not None: + cfg = Config.with_configs(self.config, config) input.workspace_id = resolve_workspace_id_by_name( - input.workspace_name, config + input.workspace_name, cfg ) input.workspace_name = None @@ -390,18 +392,22 @@ async def list_async( "workspace_ids and workspace_names are mutually exclusive;" " please only set one of them." ) - if input.workspace_name: - input.workspace_id = await resolve_workspace_id_by_name_async( - input.workspace_name, config - ) - input.workspace_name = None - if input.workspace_names: - input.workspace_ids = ( - await resolve_workspace_ids_by_names_async( - input.workspace_names, config + if input.workspace_name is not None or input.workspace_names: + cfg = Config.with_configs(self.config, config) + if input.workspace_name is not None: + input.workspace_id = ( + await resolve_workspace_id_by_name_async( + input.workspace_name, cfg + ) ) - ) - input.workspace_names = None + input.workspace_name = None + if input.workspace_names: + input.workspace_ids = ( + await resolve_workspace_ids_by_names_async( + input.workspace_names, cfg + ) + ) + input.workspace_names = None results = await self.__control_api.list_agent_runtimes_async( ListAgentRuntimesRequest().from_map(input.model_dump()), @@ -450,16 +456,18 @@ def list( "workspace_ids and workspace_names are mutually exclusive;" " please only set one of them." ) - if input.workspace_name: - input.workspace_id = resolve_workspace_id_by_name( - input.workspace_name, config - ) - input.workspace_name = None - if input.workspace_names: - input.workspace_ids = resolve_workspace_ids_by_names( - input.workspace_names, config - ) - input.workspace_names = None + if input.workspace_name is not None or input.workspace_names: + cfg = Config.with_configs(self.config, config) + if input.workspace_name is not None: + input.workspace_id = resolve_workspace_id_by_name( + input.workspace_name, cfg + ) + input.workspace_name = None + if input.workspace_names: + input.workspace_ids = resolve_workspace_ids_by_names( + input.workspace_names, cfg + ) + input.workspace_names = None results = self.__control_api.list_agent_runtimes( ListAgentRuntimesRequest().from_map(input.model_dump()), diff --git a/tests/unittests/agent_runtime/test_workspace.py b/tests/unittests/agent_runtime/test_workspace.py index 9ba9feb..2d87e55 100644 --- a/tests/unittests/agent_runtime/test_workspace.py +++ b/tests/unittests/agent_runtime/test_workspace.py @@ -18,6 +18,7 @@ AgentRuntimeCreateInput, AgentRuntimeListInput, ) +from agentrun.utils.config import Config from agentrun.utils.exception import ResourceNotExistError CONTROL_API_PATH = "agentrun.agent_runtime.client.AgentRuntimeControlAPI" @@ -276,7 +277,11 @@ def test_create_with_workspace_name_resolves( ) client.create(inp) - mock_resolve.assert_called_once_with("my-ws", None) + # 解析器收到 (name, effective_config);effective_config 应是 Config 实例 + mock_resolve.assert_called_once() + args, _ = mock_resolve.call_args + assert args[0] == "my-ws" + assert isinstance(args[1], Config) # 调用 API 前,应把 workspace_name 清空且 workspace_id 写好 assert inp.workspace_id == "ws-resolved" assert inp.workspace_name is None @@ -332,7 +337,10 @@ def test_create_async_resolves_workspace_name( ) asyncio.run(client.create_async(inp)) - mock_resolve_async.assert_awaited_once_with("my-ws", None) + mock_resolve_async.assert_awaited_once() + args, _ = mock_resolve_async.call_args + assert args[0] == "my-ws" + assert isinstance(args[1], Config) assert inp.workspace_id == "ws-resolved-async" assert inp.workspace_name is None @@ -370,8 +378,14 @@ def test_list_resolves_workspace_name_and_names( ) client.list(inp) - mock_resolve_one.assert_called_once_with("my-ws", None) - mock_resolve_many.assert_called_once_with("ws-a,ws-b", None) + mock_resolve_one.assert_called_once() + one_args, _ = mock_resolve_one.call_args + assert one_args[0] == "my-ws" + assert isinstance(one_args[1], Config) + mock_resolve_many.assert_called_once() + many_args, _ = mock_resolve_many.call_args + assert many_args[0] == "ws-a,ws-b" + assert isinstance(many_args[1], Config) assert inp.workspace_id == "ws-1" assert inp.workspace_ids == "ws-1,ws-2" assert inp.workspace_name is None @@ -439,7 +453,238 @@ def test_list_async_resolves( ) asyncio.run(client.list_async(inp)) - mock_resolve_one_async.assert_awaited_once_with("my-ws", None) - mock_resolve_many_async.assert_awaited_once_with("ws-a,ws-b", None) + mock_resolve_one_async.assert_awaited_once() + one_args, _ = mock_resolve_one_async.call_args + assert one_args[0] == "my-ws" + assert isinstance(one_args[1], Config) + mock_resolve_many_async.assert_awaited_once() + many_args, _ = mock_resolve_many_async.call_args + assert many_args[0] == "ws-a,ws-b" + assert isinstance(many_args[1], Config) assert inp.workspace_id == "ws-1" assert inp.workspace_ids == "ws-1,ws-2" + + +class TestClientEffectiveConfig: + """`self.config + method config` 合并后传入 workspace 解析器, + 避免解析与后续 OpenAPI 调用走不同凭证 / region。""" + + @patch(CONTROL_API_PATH) + @patch( + "agentrun.agent_runtime.client.resolve_workspace_id_by_name", + return_value="ws-resolved", + ) + def test_create_merges_client_config_when_method_config_none( + self, mock_resolve, mock_control_api_class + ): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_control_api = MagicMock() + mock_data = MagicMock() + mock_data.agent_runtime_id = "ar-1" + mock_data.to_map.return_value = { + "agentRuntimeId": "ar-1", + "agentRuntimeName": "n", + "status": "READY", + } + mock_control_api.create_agent_runtime.return_value = mock_data + mock_control_api_class.return_value = mock_control_api + + client_cfg = Config( + access_key_id="ak-client", + access_key_secret="sk-client", + region_id="cn-hangzhou", + ) + client = AgentRuntimeClient(config=client_cfg) + inp = AgentRuntimeCreateInput( + agent_runtime_name="n", + workspace_name="my-ws", + container_configuration=AgentRuntimeContainer( + image="img", port=9000 + ), + ) + # method-level config 故意不传,期望解析器拿到 client_cfg 的凭证 + client.create(inp) + + mock_resolve.assert_called_once() + args, _ = mock_resolve.call_args + cfg_arg = args[1] + assert isinstance(cfg_arg, Config) + assert cfg_arg.get_access_key_id() == "ak-client" + assert cfg_arg.get_region_id() == "cn-hangzhou" + + @patch(CONTROL_API_PATH) + @patch( + "agentrun.agent_runtime.client.resolve_workspace_id_by_name_async", + new_callable=AsyncMock, + ) + def test_create_async_method_config_overrides_client_config( + self, mock_resolve_async, mock_control_api_class + ): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_resolve_async.return_value = "ws-resolved-async" + mock_control_api = MagicMock() + mock_data = MagicMock() + mock_data.agent_runtime_id = "ar-1" + mock_data.to_map.return_value = { + "agentRuntimeId": "ar-1", + "agentRuntimeName": "n", + "status": "READY", + } + mock_control_api.create_agent_runtime_async = AsyncMock( + return_value=mock_data + ) + mock_control_api_class.return_value = mock_control_api + + client_cfg = Config( + access_key_id="ak-client", + access_key_secret="sk-client", + region_id="cn-hangzhou", + ) + method_cfg = Config( + access_key_id="ak-method", + access_key_secret="sk-method", + region_id="cn-shanghai", + ) + client = AgentRuntimeClient(config=client_cfg) + inp = AgentRuntimeCreateInput( + agent_runtime_name="n", + workspace_name="my-ws", + container_configuration=AgentRuntimeContainer( + image="img", port=9000 + ), + ) + asyncio.run(client.create_async(inp, config=method_cfg)) + + mock_resolve_async.assert_awaited_once() + args, _ = mock_resolve_async.call_args + cfg_arg = args[1] + # method config 应该覆盖 client config(Config.update 后者胜出) + assert cfg_arg.get_access_key_id() == "ak-method" + assert cfg_arg.get_region_id() == "cn-shanghai" + + @patch(CONTROL_API_PATH) + @patch( + "agentrun.agent_runtime.client.resolve_workspace_id_by_name", + return_value="ws-1", + ) + def test_list_merges_client_config_when_method_config_none( + self, mock_resolve, mock_control_api_class + ): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_control_api = MagicMock() + mock_result = MagicMock() + mock_result.items = [] + mock_control_api.list_agent_runtimes.return_value = mock_result + mock_control_api_class.return_value = mock_control_api + + client_cfg = Config( + access_key_id="ak-list", + access_key_secret="sk-list", + region_id="cn-beijing", + ) + client = AgentRuntimeClient(config=client_cfg) + inp = AgentRuntimeListInput(workspace_name="my-ws") + client.list(inp) + + mock_resolve.assert_called_once() + args, _ = mock_resolve.call_args + cfg_arg = args[1] + assert cfg_arg.get_access_key_id() == "ak-list" + assert cfg_arg.get_region_id() == "cn-beijing" + + @patch(CONTROL_API_PATH) + @patch( + "agentrun.agent_runtime.client.resolve_workspace_ids_by_names_async", + new_callable=AsyncMock, + ) + def test_list_async_merges_client_config_for_plural( + self, mock_resolve_many_async, mock_control_api_class + ): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_resolve_many_async.return_value = "ws-1,ws-2" + mock_control_api = MagicMock() + mock_result = MagicMock() + mock_result.items = [] + mock_control_api.list_agent_runtimes_async = AsyncMock( + return_value=mock_result + ) + mock_control_api_class.return_value = mock_control_api + + client_cfg = Config( + access_key_id="ak-list-async", + access_key_secret="sk-list-async", + region_id="cn-beijing", + ) + client = AgentRuntimeClient(config=client_cfg) + inp = AgentRuntimeListInput(workspace_names="ws-a,ws-b") + asyncio.run(client.list_async(inp)) + + mock_resolve_many_async.assert_awaited_once() + args, _ = mock_resolve_many_async.call_args + cfg_arg = args[1] + assert cfg_arg.get_access_key_id() == "ak-list-async" + assert cfg_arg.get_region_id() == "cn-beijing" + + +class TestClientEmptyWorkspaceName: + """空字符串的 workspace_name 应当显式报错而非被默默吞掉。""" + + @patch(CONTROL_API_PATH) + def test_create_empty_workspace_name_raises(self, mock_control_api_class): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_control_api_class.return_value = MagicMock() + client = AgentRuntimeClient() + inp = AgentRuntimeCreateInput( + agent_runtime_name="n", + workspace_name="", + container_configuration=AgentRuntimeContainer( + image="img", port=9000 + ), + ) + with pytest.raises(ValueError, match="non-empty"): + client.create(inp) + + @patch(CONTROL_API_PATH) + def test_create_async_empty_workspace_name_raises( + self, mock_control_api_class + ): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_control_api_class.return_value = MagicMock() + client = AgentRuntimeClient() + inp = AgentRuntimeCreateInput( + agent_runtime_name="n", + workspace_name="", + container_configuration=AgentRuntimeContainer( + image="img", port=9000 + ), + ) + with pytest.raises(ValueError, match="non-empty"): + asyncio.run(client.create_async(inp)) + + @patch(CONTROL_API_PATH) + def test_list_empty_workspace_name_raises(self, mock_control_api_class): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_control_api_class.return_value = MagicMock() + client = AgentRuntimeClient() + inp = AgentRuntimeListInput(workspace_name="") + with pytest.raises(ValueError, match="non-empty"): + client.list(inp) + + @patch(CONTROL_API_PATH) + def test_list_async_empty_workspace_name_raises( + self, mock_control_api_class + ): + from agentrun.agent_runtime.client import AgentRuntimeClient + + mock_control_api_class.return_value = MagicMock() + client = AgentRuntimeClient() + inp = AgentRuntimeListInput(workspace_name="") + with pytest.raises(ValueError, match="non-empty"): + asyncio.run(client.list_async(inp)) From f9b5e6790f6be9094dec50775c396523071bb53c Mon Sep 17 00:00:00 2001 From: Sodawyx Date: Wed, 20 May 2026 15:01:54 +0800 Subject: [PATCH 4/4] fix(agent_runtime): paginate workspace lookup & avoid input mutation Address two issues raised in PR #102 review by Ohyee: 1. Workspace lookup was hardcoded to a single page (page_size=50). If the upstream `name=` filter is server-side prefix/fuzzy match, a busy account can easily exceed 50 same-prefix workspaces and the resolver would silently miss the target. `_lookup_sync` / `_lookup_async` now paginate, accumulating candidates across pages and short-circuiting when a page returns fewer than `_PAGE_SIZE` rows; a `_MAX_PAGES=20` safety cap (1000 candidates) prevents runaway loops on misbehaving upstreams. 2. The workspace_name resolution path mutated the caller's input object (`input.workspace_id = ...; input.workspace_name = None`), which is surprising for a side-effect-free convenience field. Replaced with `input = input.model_copy(update={...})` so the caller's instance is untouched. Applied to create / create_async / list / list_async in client.py and mirrored into the codegen template. Tests: - 4 new pagination cases (multi-page accumulation, short-page early stop, MAX_PAGES cap, async multi-page). - 4 existing mutation assertions flipped to "caller's input stays unchanged; OpenAPI request carries the resolved id". agent_runtime suite 266 passed / 2 skipped; `_workspace.py` 95.62% line/branch. Full repo 3481 passed. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Sodawyx --- .../agent_runtime/__client_async_template.py | 16 ++- agentrun/agent_runtime/_workspace.py | 73 ++++++++--- agentrun/agent_runtime/client.py | 32 +++-- .../unittests/agent_runtime/test_workspace.py | 117 ++++++++++++++++-- 4 files changed, 193 insertions(+), 45 deletions(-) diff --git a/agentrun/agent_runtime/__client_async_template.py b/agentrun/agent_runtime/__client_async_template.py index 2c466d1..1880cf4 100644 --- a/agentrun/agent_runtime/__client_async_template.py +++ b/agentrun/agent_runtime/__client_async_template.py @@ -90,10 +90,12 @@ async def create_async( ) if input.workspace_name is not None: cfg = Config.with_configs(self.config, config) - input.workspace_id = await resolve_workspace_id_by_name_async( + resolved_id = await resolve_workspace_id_by_name_async( input.workspace_name, cfg ) - input.workspace_name = None + input = input.model_copy( + update={"workspace_id": resolved_id, "workspace_name": None} + ) if input.network_configuration is None: input.network_configuration = NetworkConfig() @@ -244,20 +246,22 @@ async def list_async( ) 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: - input.workspace_id = ( + update["workspace_id"] = ( await resolve_workspace_id_by_name_async( input.workspace_name, cfg ) ) - input.workspace_name = None + update["workspace_name"] = None if input.workspace_names: - input.workspace_ids = ( + update["workspace_ids"] = ( await resolve_workspace_ids_by_names_async( input.workspace_names, cfg ) ) - input.workspace_names = None + 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()), diff --git a/agentrun/agent_runtime/_workspace.py b/agentrun/agent_runtime/_workspace.py index cab07dd..a6caeb8 100644 --- a/agentrun/agent_runtime/_workspace.py +++ b/agentrun/agent_runtime/_workspace.py @@ -30,6 +30,13 @@ # 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 ( @@ -113,35 +120,69 @@ def _lookup_sync( self, name: str, config: Optional[Config] = None ) -> Optional[Workspace]: client = self._get_client(config) + accumulated: List[Workspace] = [] + page_number = 1 try: - response = client.list_workspaces( - ListWorkspacesRequest(name=name, page_size="50") - ) + 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 - workspaces = ( - getattr(getattr(response.body, "data", None), "workspaces", None) - or [] - ) - return _pick_exact_match(workspaces, name) + 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: - response = await client.list_workspaces_async( - ListWorkspacesRequest(name=name, page_size="50") - ) + 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 - workspaces = ( - getattr(getattr(response.body, "data", None), "workspaces", None) - or [] - ) - return _pick_exact_match(workspaces, name) + return _pick_exact_match(accumulated, name) def resolve_workspace_id_by_name( diff --git a/agentrun/agent_runtime/client.py b/agentrun/agent_runtime/client.py index 5d09261..8d6c6bc 100644 --- a/agentrun/agent_runtime/client.py +++ b/agentrun/agent_runtime/client.py @@ -100,10 +100,12 @@ async def create_async( ) if input.workspace_name is not None: cfg = Config.with_configs(self.config, config) - input.workspace_id = await resolve_workspace_id_by_name_async( + resolved_id = await resolve_workspace_id_by_name_async( input.workspace_name, cfg ) - input.workspace_name = None + input = input.model_copy( + update={"workspace_id": resolved_id, "workspace_name": None} + ) if input.network_configuration is None: input.network_configuration = NetworkConfig() @@ -159,10 +161,12 @@ def create( ) if input.workspace_name is not None: cfg = Config.with_configs(self.config, config) - input.workspace_id = resolve_workspace_id_by_name( + resolved_id = resolve_workspace_id_by_name( input.workspace_name, cfg ) - input.workspace_name = None + input = input.model_copy( + update={"workspace_id": resolved_id, "workspace_name": None} + ) if input.network_configuration is None: input.network_configuration = NetworkConfig() @@ -394,20 +398,22 @@ async def list_async( ) 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: - input.workspace_id = ( + update["workspace_id"] = ( await resolve_workspace_id_by_name_async( input.workspace_name, cfg ) ) - input.workspace_name = None + update["workspace_name"] = None if input.workspace_names: - input.workspace_ids = ( + update["workspace_ids"] = ( await resolve_workspace_ids_by_names_async( input.workspace_names, cfg ) ) - input.workspace_names = None + 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()), @@ -458,16 +464,18 @@ def list( ) 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: - input.workspace_id = resolve_workspace_id_by_name( + update["workspace_id"] = resolve_workspace_id_by_name( input.workspace_name, cfg ) - input.workspace_name = None + update["workspace_name"] = None if input.workspace_names: - input.workspace_ids = resolve_workspace_ids_by_names( + update["workspace_ids"] = resolve_workspace_ids_by_names( input.workspace_names, cfg ) - input.workspace_names = None + update["workspace_names"] = None + input = input.model_copy(update=update) results = self.__control_api.list_agent_runtimes( ListAgentRuntimesRequest().from_map(input.model_dump()), diff --git a/tests/unittests/agent_runtime/test_workspace.py b/tests/unittests/agent_runtime/test_workspace.py index 2d87e55..7a78ac4 100644 --- a/tests/unittests/agent_runtime/test_workspace.py +++ b/tests/unittests/agent_runtime/test_workspace.py @@ -242,6 +242,81 @@ def test_resolve_many_async(self, monkeypatch): assert result == "ws-a,ws-b" +class TestResolveWorkspacePagination: + """翻页累积:避免 server-side name 模糊匹配下 50 条单页漏匹配。""" + + def test_paginates_until_exact_match_across_pages(self, monkeypatch): + # 第 1 页 50 条都是前缀匹配但非 exact;第 2 页才含 exact,必须翻页 + page1 = [_make_ws(f"my-ws-{i}", f"ws-p1-{i}") for i in range(50)] + page2 = [_make_ws("my-ws", "ws-target"), _make_ws("my-ws-x", "ws-x")] + client = MagicMock() + client.list_workspaces.side_effect = [ + _make_response(page1), + _make_response(page2), + ] + monkeypatch.setattr( + ws_mod._WorkspaceResolver, + "_get_client", + lambda self, config=None: client, + ) + assert resolve_workspace_id_by_name("my-ws") == "ws-target" + # 必须翻到第二页才能找到,因此至少 2 次调用 + assert client.list_workspaces.call_count == 2 + # page_number 应该递增 + calls = client.list_workspaces.call_args_list + assert calls[0].args[0].page_number == "1" + assert calls[1].args[0].page_number == "2" + + def test_short_page_breaks_pagination_early(self, monkeypatch): + # 单页返回 < page_size 即视为末页,不再翻页 + client = MagicMock() + client.list_workspaces.return_value = _make_response( + [_make_ws("my-ws", "ws-1")] + ) + monkeypatch.setattr( + ws_mod._WorkspaceResolver, + "_get_client", + lambda self, config=None: client, + ) + assert resolve_workspace_id_by_name("my-ws") == "ws-1" + # 仅查 1 页就停(mock 返回 1 条 < 50) + assert client.list_workspaces.call_count == 1 + + def test_pagination_respects_max_pages_cap(self, monkeypatch): + # 异常情况:上游一直返回满页(不存在 exact match), + # 必须在 _MAX_PAGES 处停止,避免死循环。 + full_page = [_make_ws(f"prefix-{i}", f"id-{i}") for i in range(50)] + client = MagicMock() + client.list_workspaces.return_value = _make_response(full_page) + monkeypatch.setattr( + ws_mod._WorkspaceResolver, + "_get_client", + lambda self, config=None: client, + ) + with pytest.raises(ResourceNotExistError): + resolve_workspace_id_by_name("absent-target") + # 不应超过安全上限 + assert client.list_workspaces.call_count == ws_mod._MAX_PAGES + + def test_async_paginates_across_pages(self, monkeypatch): + page1 = [_make_ws(f"my-ws-{i}", f"ws-p1-{i}") for i in range(50)] + page2 = [_make_ws("my-ws", "ws-target-async")] + client = MagicMock() + client.list_workspaces_async = AsyncMock( + side_effect=[_make_response(page1), _make_response(page2)] + ) + monkeypatch.setattr( + ws_mod._WorkspaceResolver, + "_get_client", + lambda self, config=None: client, + ) + assert ( + asyncio.run(resolve_workspace_id_by_name_async("my-ws")) + == "ws-target-async" + ) + assert client.list_workspaces_async.await_count == 2 + + class TestClientCreateWorkspaceResolution: """create 集成测试:workspace_name 自动转换为 workspace_id""" @@ -282,9 +357,13 @@ def test_create_with_workspace_name_resolves( args, _ = mock_resolve.call_args assert args[0] == "my-ws" assert isinstance(args[1], Config) - # 调用 API 前,应把 workspace_name 清空且 workspace_id 写好 - assert inp.workspace_id == "ws-resolved" - assert inp.workspace_name is None + # 调用方传入的 input 对象不应被 mutate + assert inp.workspace_id is None + assert inp.workspace_name == "my-ws" + # OpenAPI 收到的对象应带解析后的 workspace_id(workspace_name + # 是 SDK 侧便利字段,OpenAPI 模型本就没有) + api_input = mock_control_api.create_agent_runtime.call_args.args[0] + assert api_input.workspace_id == "ws-resolved" @patch(CONTROL_API_PATH) def test_create_rejects_both_workspace_fields(self, mock_control_api_class): @@ -341,8 +420,13 @@ def test_create_async_resolves_workspace_name( args, _ = mock_resolve_async.call_args assert args[0] == "my-ws" assert isinstance(args[1], Config) - assert inp.workspace_id == "ws-resolved-async" - assert inp.workspace_name is None + # 调用方传入的 input 对象不应被 mutate + assert inp.workspace_id is None + assert inp.workspace_name == "my-ws" + api_input = mock_control_api.create_agent_runtime_async.call_args.args[ + 0 + ] + assert api_input.workspace_id == "ws-resolved-async" class TestClientListWorkspaceResolution: @@ -386,10 +470,15 @@ def test_list_resolves_workspace_name_and_names( many_args, _ = mock_resolve_many.call_args assert many_args[0] == "ws-a,ws-b" assert isinstance(many_args[1], Config) - assert inp.workspace_id == "ws-1" - assert inp.workspace_ids == "ws-1,ws-2" - assert inp.workspace_name is None - assert inp.workspace_names is None + # 调用方传入的 input 对象不应被 mutate + assert inp.workspace_id is None + assert inp.workspace_ids is None + assert inp.workspace_name == "my-ws" + assert inp.workspace_names == "ws-a,ws-b" + # OpenAPI 收到的对象应带解析后的 ID + api_input = mock_control_api.list_agent_runtimes.call_args.args[0] + assert api_input.workspace_id == "ws-1" + assert api_input.workspace_ids == "ws-1,ws-2" @patch(CONTROL_API_PATH) def test_list_rejects_both_singular(self, mock_control_api_class): @@ -461,8 +550,14 @@ def test_list_async_resolves( many_args, _ = mock_resolve_many_async.call_args assert many_args[0] == "ws-a,ws-b" assert isinstance(many_args[1], Config) - assert inp.workspace_id == "ws-1" - assert inp.workspace_ids == "ws-1,ws-2" + # 调用方传入的 input 对象不应被 mutate + assert inp.workspace_id is None + assert inp.workspace_ids is None + assert inp.workspace_name == "my-ws" + assert inp.workspace_names == "ws-a,ws-b" + api_input = mock_control_api.list_agent_runtimes_async.call_args.args[0] + assert api_input.workspace_id == "ws-1" + assert api_input.workspace_ids == "ws-1,ws-2" class TestClientEffectiveConfig: