From ea1f1c43137d4afb2ed9770c2d242c27003de150 Mon Sep 17 00:00:00 2001 From: Sodawyx Date: Sat, 25 Apr 2026 14:18:02 +0800 Subject: [PATCH] feat(super-agent): make modelService/modelName fields optional Backend now allows modelServiceName / modelName to be absent from create and invoke JSON. SDK omits unset scalar / empty-list fields from forwardedProps on create and invoke paths while preserving null-as-clear semantics on update. - Add _prune_forwarded_props helper in api/control.py. - Plumb prune_props flag through _build_protocol_settings_config and _build_protocol_configuration; to_create_input opts in, to_update_input stays default-False so update(model_name=None) still clears. - Prune forwarded_extras inside SuperAgentDataAPI._build_invoke_body before injecting SDK-managed metadata / conversationId; drop any leaked conversationId when caller passes conversation_id=None. - Sync agentrun/super_agent/api/__data_async_template.py and agentrun/super_agent/__agent_async_template.py so codegen cannot revert the change; clarify _forwarded_business_fields docstring to point at the downstream pruning step. - Tests: 6 unit tests for the helper, 4 new tests for create / invoke pruning behavior, 2 regression tests locking in update null preservation, 1 test for the defensive conversationId pop. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Sodawyx --- .../super_agent/__agent_async_template.py | 7 +- agentrun/super_agent/agent.py | 7 +- .../super_agent/api/__data_async_template.py | 11 +- agentrun/super_agent/api/control.py | 56 ++++++- agentrun/super_agent/api/data.py | 11 +- tests/unittests/super_agent/test_control.py | 151 +++++++++++++++++- tests/unittests/super_agent/test_data_api.py | 101 ++++++++++++ 7 files changed, 330 insertions(+), 14 deletions(-) diff --git a/agentrun/super_agent/__agent_async_template.py b/agentrun/super_agent/__agent_async_template.py index 9f5ba8b..895168c 100644 --- a/agentrun/super_agent/__agent_async_template.py +++ b/agentrun/super_agent/__agent_async_template.py @@ -58,9 +58,10 @@ def _resolve_config(self, config: Optional[Config]) -> Config: def _forwarded_business_fields(self) -> Dict[str, Any]: """把 SuperAgent 实例字段打包成 ``forwardedProps`` 顶层业务字段 dict. - 与 ``protocolSettings[0].config`` 写入时的结构保持对称: list 型用 ``[]`` - 代替 None, scalar 型保留 None (由 JSON 序列化为 ``null``)。服务端读取同 - 一份语义, 避免客户端/服务端对"未设置"产生歧义。 + 本层输出 "完整 dict" (list 型用 ``[]`` 代替 None, scalar 型保留 None), + 下游 :func:`agentrun.super_agent.api.data.SuperAgentDataAPI._build_invoke_body` + 会调用 :func:`_prune_forwarded_props` 把 None scalar 和空 list 过滤掉, + 仅保留 ``metadata`` / ``conversationId`` 等 SDK 托管字段。 """ return { "prompt": self.prompt, diff --git a/agentrun/super_agent/agent.py b/agentrun/super_agent/agent.py index 2460041..054a5ba 100644 --- a/agentrun/super_agent/agent.py +++ b/agentrun/super_agent/agent.py @@ -66,9 +66,10 @@ def _resolve_config(self, config: Optional[Config]) -> Config: def _forwarded_business_fields(self) -> Dict[str, Any]: """把 SuperAgent 实例字段打包成 ``forwardedProps`` 顶层业务字段 dict. - 与 ``protocolSettings[0].config`` 写入时的结构保持对称: list 型用 ``[]`` - 代替 None, scalar 型保留 None (由 JSON 序列化为 ``null``)。服务端读取同 - 一份语义, 避免客户端/服务端对"未设置"产生歧义。 + 本层输出 "完整 dict" (list 型用 ``[]`` 代替 None, scalar 型保留 None), + 下游 :func:`agentrun.super_agent.api.data.SuperAgentDataAPI._build_invoke_body` + 会调用 :func:`_prune_forwarded_props` 把 None scalar 和空 list 过滤掉, + 仅保留 ``metadata`` / ``conversationId`` 等 SDK 托管字段。 """ return { "prompt": self.prompt, diff --git a/agentrun/super_agent/api/__data_async_template.py b/agentrun/super_agent/api/__data_async_template.py index 9a7549e..6be2d7e 100644 --- a/agentrun/super_agent/api/__data_async_template.py +++ b/agentrun/super_agent/api/__data_async_template.py @@ -18,6 +18,7 @@ import httpx +from agentrun.super_agent.api.control import _prune_forwarded_props from agentrun.super_agent.model import InvokeResponseData from agentrun.super_agent.stream import parse_sse_async, SSEEvent from agentrun.utils.config import Config @@ -61,11 +62,19 @@ def _build_invoke_body( # ``forwarded_extras`` 承载从 AgentRuntime 元数据读出的业务字段 # (prompt/agents/tools/skills/sandboxes/workspaces/modelServiceName/modelName), # 由上层 ``SuperAgent.invoke_async`` 注入。``metadata`` 和 ``conversationId`` - # 由 SDK 管理, 不允许 extras 覆盖。 + # 由 SDK 管理, 不允许 extras 覆盖; 先 prune 掉 extras 里的 None scalar 和 + # 空 list (保留 SDK 即将覆写的 metadata 占位), 再把 SDK 托管字段写入。 forwarded: Dict[str, Any] = dict(forwarded_extras or {}) + forwarded = _prune_forwarded_props( + forwarded, keep_keys=("metadata", "conversationId") + ) forwarded["metadata"] = {"agentRuntimeName": self.agent_runtime_name} if conversation_id is not None: forwarded["conversationId"] = conversation_id + else: + # 即便用户 extras 里写了 conversationId (被 keep_keys 保留), + # 外部 SDK 约定 conversation_id=None 时必须不出现。 + forwarded.pop("conversationId", None) return {"messages": list(messages), "forwardedProps": forwarded} def _parse_invoke_response( diff --git a/agentrun/super_agent/api/control.py b/agentrun/super_agent/api/control.py index afaf665..0092cad 100644 --- a/agentrun/super_agent/api/control.py +++ b/agentrun/super_agent/api/control.py @@ -14,7 +14,7 @@ from __future__ import annotations import json -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING from urllib.parse import urlparse, urlunparse if TYPE_CHECKING: @@ -218,13 +218,47 @@ def _business_fields_from_args( } +def _prune_forwarded_props( + props: Dict[str, Any], + *, + keep_keys: Iterable[str] = ("metadata",), +) -> Dict[str, Any]: + """删除值为 None 的 scalar 字段和空 list 字段。 + + ``keep_keys`` 里的 key 永远保留 (即便是 None 或空 list), 用来保护 SDK 托管 + 的必要字段 (如 ``metadata`` / ``conversationId``)。 + + 语义上只处理两类: + - scalar = None → 丢弃 + - 空 list → 丢弃 + + 其他 falsy 值 (0 / False / "" / 空 dict) 保留, 因为它们是业务显式值。 + """ + keep = set(keep_keys) + out: Dict[str, Any] = {} + for k, v in props.items(): + if k in keep: + out[k] = v + continue + if v is None: + continue + if isinstance(v, list) and not v: + continue + out[k] = v + return out + + def _build_protocol_settings_config( - *, name: str, business: Dict[str, Any] + *, name: str, business: Dict[str, Any], prune_props: bool = False ) -> str: """构造 ``protocolSettings[0].config`` 的 JSON 字符串. 新结构: 顶层 ``path`` / ``headers`` / ``body``, 业务字段收拢到 ``body.forwardedProps`` (开放字典, 语义 "any, merge")。 + + ``prune_props=True`` 时, 对 forwardedProps 过一遍 :func:`_prune_forwarded_props`, + 丢弃 None scalar 和空 list 字段 (保留 ``metadata``)。create 路径使用; update + 路径使用 False, 仍写 null 以保留 "显式清空" 语义。 """ forwarded_props: Dict[str, Any] = { "prompt": business.get("prompt"), @@ -237,6 +271,10 @@ def _build_protocol_settings_config( "modelName": business.get("modelName"), "metadata": {"agentRuntimeName": name}, } + if prune_props: + forwarded_props = _prune_forwarded_props( + forwarded_props, keep_keys=("metadata",) + ) cfg_dict: Dict[str, Any] = { "path": SUPER_AGENT_INVOKE_PATH, "headers": {}, @@ -250,9 +288,15 @@ def _build_protocol_configuration( name: str, business: Dict[str, Any], cfg: Optional[Config], + prune_props: bool = False, ) -> SuperAgentProtocolConfig: - """构造超级 Agent 的 ``protocolConfiguration`` Pydantic 模型.""" - config_json = _build_protocol_settings_config(name=name, business=business) + """构造超级 Agent 的 ``protocolConfiguration`` Pydantic 模型. + + ``prune_props`` 透传到 :func:`_build_protocol_settings_config`。 + """ + config_json = _build_protocol_settings_config( + name=name, business=business, prune_props=prune_props + ) settings: List[Dict[str, Any]] = [{ "type": SUPER_AGENT_PROTOCOL_TYPE, "name": name, @@ -292,7 +336,9 @@ def to_create_input( model_service_name=model_service_name, model_name=model_name, ) - pc = _build_protocol_configuration(name=name, business=business, cfg=cfg) + pc = _build_protocol_configuration( + name=name, business=business, cfg=cfg, prune_props=True + ) # SUPER_AGENT 是平台托管运行时, 不跑用户代码/容器, 但服务端仍要求 # artifact_type / network_configuration 非空. 这里给占位默认值即可. return _SuperAgentCreateInput.model_construct( diff --git a/agentrun/super_agent/api/data.py b/agentrun/super_agent/api/data.py index 947e0b4..42fef75 100644 --- a/agentrun/super_agent/api/data.py +++ b/agentrun/super_agent/api/data.py @@ -24,6 +24,7 @@ import httpx +from agentrun.super_agent.api.control import _prune_forwarded_props from agentrun.super_agent.model import InvokeResponseData from agentrun.super_agent.stream import parse_sse_async, SSEEvent from agentrun.utils.config import Config @@ -67,11 +68,19 @@ def _build_invoke_body( # ``forwarded_extras`` 承载从 AgentRuntime 元数据读出的业务字段 # (prompt/agents/tools/skills/sandboxes/workspaces/modelServiceName/modelName), # 由上层 ``SuperAgent.invoke_async`` 注入。``metadata`` 和 ``conversationId`` - # 由 SDK 管理, 不允许 extras 覆盖。 + # 由 SDK 管理, 不允许 extras 覆盖; 先 prune 掉 extras 里的 None scalar 和 + # 空 list (保留 SDK 即将覆写的 metadata 占位), 再把 SDK 托管字段写入。 forwarded: Dict[str, Any] = dict(forwarded_extras or {}) + forwarded = _prune_forwarded_props( + forwarded, keep_keys=("metadata", "conversationId") + ) forwarded["metadata"] = {"agentRuntimeName": self.agent_runtime_name} if conversation_id is not None: forwarded["conversationId"] = conversation_id + else: + # 即便用户 extras 里写了 conversationId (被 keep_keys 保留), + # 外部 SDK 约定 conversation_id=None 时必须不出现。 + forwarded.pop("conversationId", None) return {"messages": list(messages), "forwardedProps": forwarded} def _parse_invoke_response( diff --git a/tests/unittests/super_agent/test_control.py b/tests/unittests/super_agent/test_control.py index 33d36a1..c2a31be 100644 --- a/tests/unittests/super_agent/test_control.py +++ b/tests/unittests/super_agent/test_control.py @@ -27,6 +27,60 @@ # 显式在模块加载时触发补丁 (幂等, 与 SuperAgentClient.__init__ 内触发点一致)。 ensure_super_agent_patches_applied() +# ─── _prune_forwarded_props ─────────────────────────────────── + + +def test_prune_forwarded_props_removes_none_scalars(): + from agentrun.super_agent.api.control import _prune_forwarded_props + + out = _prune_forwarded_props({"a": None, "b": "x"}) + assert out == {"b": "x"} + + +def test_prune_forwarded_props_removes_empty_lists(): + from agentrun.super_agent.api.control import _prune_forwarded_props + + out = _prune_forwarded_props({"a": [], "b": ["x"]}) + assert out == {"b": ["x"]} + + +def test_prune_forwarded_props_keeps_keep_keys_even_when_none(): + from agentrun.super_agent.api.control import _prune_forwarded_props + + out = _prune_forwarded_props( + {"metadata": None, "other": None}, keep_keys=("metadata",) + ) + assert out == {"metadata": None} + + +def test_prune_forwarded_props_keeps_keep_keys_even_when_empty_list(): + from agentrun.super_agent.api.control import _prune_forwarded_props + + out = _prune_forwarded_props( + {"metadata": [], "other": []}, keep_keys=("metadata",) + ) + assert out == {"metadata": []} + + +def test_prune_forwarded_props_preserves_falsy_non_none_scalars(): + """0, False, "" 不应被剔除 (只有 None 或空 list).""" + from agentrun.super_agent.api.control import _prune_forwarded_props + + out = _prune_forwarded_props({"n": 0, "b": False, "s": ""}) + assert out == {"n": 0, "b": False, "s": ""} + + +def test_prune_forwarded_props_preserves_non_empty_lists_and_dicts(): + from agentrun.super_agent.api.control import _prune_forwarded_props + + out = _prune_forwarded_props({ + "list": ["x"], + "dict_empty": {}, # dict 不算 list, 保留 + "dict_full": {"k": "v"}, + }) + assert out == {"list": ["x"], "dict_empty": {}, "dict_full": {"k": "v"}} + + # ─── build_super_agent_endpoint ──────────────────────────────── @@ -116,11 +170,50 @@ def test_to_create_input_minimal(): cfg_dict = json.loads(settings[0]["config"]) assert cfg_dict["path"] == "/invoke" assert cfg_dict["headers"] == {} + # 具体字段缺席断言放到 test_to_create_input_minimal_omits_unset_scalar_and_empty_list_fields forwarded = cfg_dict["body"]["forwardedProps"] - assert forwarded["agents"] == [] assert forwarded["metadata"] == {"agentRuntimeName": "alpha"} +def test_to_create_input_minimal_omits_unset_scalar_and_empty_list_fields(): + """create 时, 未设置的 scalar 字段和空 list 字段 MUST NOT 出现在 forwardedProps 里.""" + cfg = Config(account_id="123", region_id="cn-hangzhou") + inp = to_create_input("alpha", cfg=cfg) + cfg_dict = json.loads( + inp.protocol_configuration.protocol_settings[0]["config"] + ) + forwarded = cfg_dict["body"]["forwardedProps"] + # metadata 永远保留 + assert forwarded["metadata"] == {"agentRuntimeName": "alpha"} + # 未设置的 scalar 字段缺席 + assert "prompt" not in forwarded + assert "modelServiceName" not in forwarded + assert "modelName" not in forwarded + # 空 list 字段缺席 + assert "agents" not in forwarded + assert "tools" not in forwarded + assert "skills" not in forwarded + assert "sandboxes" not in forwarded + assert "workspaces" not in forwarded + + +def test_to_create_input_partial_only_keeps_set_fields(): + """仅设置部分字段时, 未设置的字段不出现, 已设置的字段按原值出现.""" + cfg = Config(account_id="123", region_id="cn-hangzhou") + inp = to_create_input( + "bravo", prompt="hello", model_service_name="svc", cfg=cfg + ) + cfg_dict = json.loads( + inp.protocol_configuration.protocol_settings[0]["config"] + ) + forwarded = cfg_dict["body"]["forwardedProps"] + assert forwarded["prompt"] == "hello" + assert forwarded["modelServiceName"] == "svc" + assert "modelName" not in forwarded + assert "agents" not in forwarded + assert forwarded["metadata"] == {"agentRuntimeName": "bravo"} + + def test_to_create_input_full(): cfg = Config(account_id="123", region_id="cn-hangzhou") inp = to_create_input( @@ -382,6 +475,62 @@ def test_to_update_input_full_protocol_replace(): assert forwarded["tools"] == ["t"] +def test_to_update_input_keeps_null_for_none_scalars(): + """update 路径: 合并后为 None 的 scalar 字段 MUST 仍写 null (不剪除). + + 保证 SDK 的 'update(model_name=None) 表示清空' 语义不被本次 PR 破坏。 + """ + cfg = Config(account_id="123", region_id="cn-hangzhou") + merged = { + "prompt": None, + "agents": [], + "tools": [], + "skills": [], + "sandboxes": [], + "workspaces": [], + "model_service_name": None, + "model_name": None, + } + inp = to_update_input("u1", merged, cfg=cfg) + cfg_dict = json.loads( + inp.protocol_configuration.protocol_settings[0]["config"] + ) + forwarded = cfg_dict["body"]["forwardedProps"] + # 明确包含 null (未被剪除) + assert "prompt" in forwarded and forwarded["prompt"] is None + assert ( + "modelServiceName" in forwarded + and forwarded["modelServiceName"] is None + ) + assert "modelName" in forwarded and forwarded["modelName"] is None + # 空 list 也保留 (update 语义下 [] 代表 "清空列表", 不能剪除) + assert forwarded["agents"] == [] + assert forwarded["tools"] == [] + + +def test_to_update_input_keeps_values_and_nulls_mixed(): + """update: 有些字段有值, 有些是 None, 都应完整出现在 payload.""" + cfg = Config(account_id="123", region_id="cn-hangzhou") + merged = { + "prompt": "new", + "agents": ["a"], + "model_service_name": None, + "model_name": "m", + } + inp = to_update_input("u2", merged, cfg=cfg) + cfg_dict = json.loads( + inp.protocol_configuration.protocol_settings[0]["config"] + ) + forwarded = cfg_dict["body"]["forwardedProps"] + assert forwarded["prompt"] == "new" + assert forwarded["agents"] == ["a"] + assert ( + "modelServiceName" in forwarded + and forwarded["modelServiceName"] is None + ) + assert forwarded["modelName"] == "m" + + # ─── Dara ListAgentRuntimesRequest systemTags 原生字段 ────────────── # ``systemTags`` 已由 Dara SDK 原生支持, 无需补丁。以下测试只校验 pydantic → # Dara roundtrip 能把 ``system_tags`` 保留到请求 query。 diff --git a/tests/unittests/super_agent/test_data_api.py b/tests/unittests/super_agent/test_data_api.py index ec2d7e2..13cdd3f 100644 --- a/tests/unittests/super_agent/test_data_api.py +++ b/tests/unittests/super_agent/test_data_api.py @@ -280,6 +280,107 @@ def _responder(request): assert fp["prompt"] == "p" +@respx.mock +async def test_invoke_async_body_prunes_none_scalars_in_extras(): + """数据面: extras 中值为 None 的 scalar 字段 MUST NOT 进入 forwardedProps.""" + cfg = _auth_cfg() + api = SuperAgentDataAPI("demo", config=cfg) + captured = {} + + def _responder(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "data": { + "conversationId": "c", + "url": "https://s", + "headers": {}, + } + }, + ) + + respx.post(re.compile(r".*/invoke$")).mock(side_effect=_responder) + await api.invoke_async( + [{"role": "user", "content": "hi"}], + forwarded_extras={ + "prompt": None, + "modelServiceName": None, + "modelName": None, + "agents": [], + "tools": ["t1"], + }, + ) + fp = captured["body"]["forwardedProps"] + assert "prompt" not in fp + assert "modelServiceName" not in fp + assert "modelName" not in fp + assert "agents" not in fp + assert fp["tools"] == ["t1"] + # SDK 托管字段保留 + assert fp["metadata"] == {"agentRuntimeName": "demo"} + + +@respx.mock +async def test_invoke_async_body_metadata_preserved_even_if_none_in_extras(): + """extras 里带 metadata=None 也不应覆盖 SDK 管理的 metadata.""" + cfg = _auth_cfg() + api = SuperAgentDataAPI("demo", config=cfg) + captured = {} + + def _responder(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "data": { + "conversationId": "c", + "url": "https://s", + "headers": {}, + } + }, + ) + + respx.post(re.compile(r".*/invoke$")).mock(side_effect=_responder) + await api.invoke_async( + [{"role": "user", "content": "hi"}], + forwarded_extras={"metadata": None}, + ) + fp = captured["body"]["forwardedProps"] + assert fp["metadata"] == {"agentRuntimeName": "demo"} + + +@respx.mock +async def test_invoke_async_body_drops_leaked_conversation_id_from_extras(): + """extras 里若带 conversationId 但 kwarg 里 conversation_id=None, 最终 payload 不含 conversationId. + + 回归保护 _build_invoke_body 里的防御性 pop 分支。 + """ + cfg = _auth_cfg() + api = SuperAgentDataAPI("demo", config=cfg) + captured = {} + + def _responder(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "data": { + "conversationId": "c", + "url": "https://s", + "headers": {}, + } + }, + ) + + respx.post(re.compile(r".*/invoke$")).mock(side_effect=_responder) + await api.invoke_async( + [{"role": "user", "content": "hi"}], + forwarded_extras={"conversationId": "should-be-dropped"}, + ) + assert "conversationId" not in captured["body"]["forwardedProps"] + + # ─── signing ──────────────────────────────────────────────────