Skip to content

Commit 95129cf

Browse files
authored
Merge pull request #93 from Serverless-Devs/feat/super-agent-optional-model-fields
feat(super-agent): make modelService/modelName fields optional
2 parents 5bfdc40 + ea1f1c4 commit 95129cf

7 files changed

Lines changed: 330 additions & 14 deletions

File tree

agentrun/super_agent/__agent_async_template.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ def _resolve_config(self, config: Optional[Config]) -> Config:
5858
def _forwarded_business_fields(self) -> Dict[str, Any]:
5959
"""把 SuperAgent 实例字段打包成 ``forwardedProps`` 顶层业务字段 dict.
6060
61-
与 ``protocolSettings[0].config`` 写入时的结构保持对称: list 型用 ``[]``
62-
代替 None, scalar 型保留 None (由 JSON 序列化为 ``null``)。服务端读取同
63-
一份语义, 避免客户端/服务端对"未设置"产生歧义。
61+
本层输出 "完整 dict" (list 型用 ``[]`` 代替 None, scalar 型保留 None),
62+
下游 :func:`agentrun.super_agent.api.data.SuperAgentDataAPI._build_invoke_body`
63+
会调用 :func:`_prune_forwarded_props` 把 None scalar 和空 list 过滤掉,
64+
仅保留 ``metadata`` / ``conversationId`` 等 SDK 托管字段。
6465
"""
6566
return {
6667
"prompt": self.prompt,

agentrun/super_agent/agent.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,10 @@ def _resolve_config(self, config: Optional[Config]) -> Config:
6666
def _forwarded_business_fields(self) -> Dict[str, Any]:
6767
"""把 SuperAgent 实例字段打包成 ``forwardedProps`` 顶层业务字段 dict.
6868
69-
与 ``protocolSettings[0].config`` 写入时的结构保持对称: list 型用 ``[]``
70-
代替 None, scalar 型保留 None (由 JSON 序列化为 ``null``)。服务端读取同
71-
一份语义, 避免客户端/服务端对"未设置"产生歧义。
69+
本层输出 "完整 dict" (list 型用 ``[]`` 代替 None, scalar 型保留 None),
70+
下游 :func:`agentrun.super_agent.api.data.SuperAgentDataAPI._build_invoke_body`
71+
会调用 :func:`_prune_forwarded_props` 把 None scalar 和空 list 过滤掉,
72+
仅保留 ``metadata`` / ``conversationId`` 等 SDK 托管字段。
7273
"""
7374
return {
7475
"prompt": self.prompt,

agentrun/super_agent/api/__data_async_template.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import httpx
2020

21+
from agentrun.super_agent.api.control import _prune_forwarded_props
2122
from agentrun.super_agent.model import InvokeResponseData
2223
from agentrun.super_agent.stream import parse_sse_async, SSEEvent
2324
from agentrun.utils.config import Config
@@ -61,11 +62,19 @@ def _build_invoke_body(
6162
# ``forwarded_extras`` 承载从 AgentRuntime 元数据读出的业务字段
6263
# (prompt/agents/tools/skills/sandboxes/workspaces/modelServiceName/modelName),
6364
# 由上层 ``SuperAgent.invoke_async`` 注入。``metadata`` 和 ``conversationId``
64-
# 由 SDK 管理, 不允许 extras 覆盖。
65+
# 由 SDK 管理, 不允许 extras 覆盖; 先 prune 掉 extras 里的 None scalar 和
66+
# 空 list (保留 SDK 即将覆写的 metadata 占位), 再把 SDK 托管字段写入。
6567
forwarded: Dict[str, Any] = dict(forwarded_extras or {})
68+
forwarded = _prune_forwarded_props(
69+
forwarded, keep_keys=("metadata", "conversationId")
70+
)
6671
forwarded["metadata"] = {"agentRuntimeName": self.agent_runtime_name}
6772
if conversation_id is not None:
6873
forwarded["conversationId"] = conversation_id
74+
else:
75+
# 即便用户 extras 里写了 conversationId (被 keep_keys 保留),
76+
# 外部 SDK 约定 conversation_id=None 时必须不出现。
77+
forwarded.pop("conversationId", None)
6978
return {"messages": list(messages), "forwardedProps": forwarded}
7079

7180
def _parse_invoke_response(

agentrun/super_agent/api/control.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from __future__ import annotations
1515

1616
import json
17-
from typing import Any, Dict, List, Optional, TYPE_CHECKING
17+
from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING
1818
from urllib.parse import urlparse, urlunparse
1919

2020
if TYPE_CHECKING:
@@ -218,13 +218,47 @@ def _business_fields_from_args(
218218
}
219219

220220

221+
def _prune_forwarded_props(
222+
props: Dict[str, Any],
223+
*,
224+
keep_keys: Iterable[str] = ("metadata",),
225+
) -> Dict[str, Any]:
226+
"""删除值为 None 的 scalar 字段和空 list 字段。
227+
228+
``keep_keys`` 里的 key 永远保留 (即便是 None 或空 list), 用来保护 SDK 托管
229+
的必要字段 (如 ``metadata`` / ``conversationId``)。
230+
231+
语义上只处理两类:
232+
- scalar = None → 丢弃
233+
- 空 list → 丢弃
234+
235+
其他 falsy 值 (0 / False / "" / 空 dict) 保留, 因为它们是业务显式值。
236+
"""
237+
keep = set(keep_keys)
238+
out: Dict[str, Any] = {}
239+
for k, v in props.items():
240+
if k in keep:
241+
out[k] = v
242+
continue
243+
if v is None:
244+
continue
245+
if isinstance(v, list) and not v:
246+
continue
247+
out[k] = v
248+
return out
249+
250+
221251
def _build_protocol_settings_config(
222-
*, name: str, business: Dict[str, Any]
252+
*, name: str, business: Dict[str, Any], prune_props: bool = False
223253
) -> str:
224254
"""构造 ``protocolSettings[0].config`` 的 JSON 字符串.
225255
226256
新结构: 顶层 ``path`` / ``headers`` / ``body``, 业务字段收拢到
227257
``body.forwardedProps`` (开放字典, 语义 "any, merge")。
258+
259+
``prune_props=True`` 时, 对 forwardedProps 过一遍 :func:`_prune_forwarded_props`,
260+
丢弃 None scalar 和空 list 字段 (保留 ``metadata``)。create 路径使用; update
261+
路径使用 False, 仍写 null 以保留 "显式清空" 语义。
228262
"""
229263
forwarded_props: Dict[str, Any] = {
230264
"prompt": business.get("prompt"),
@@ -237,6 +271,10 @@ def _build_protocol_settings_config(
237271
"modelName": business.get("modelName"),
238272
"metadata": {"agentRuntimeName": name},
239273
}
274+
if prune_props:
275+
forwarded_props = _prune_forwarded_props(
276+
forwarded_props, keep_keys=("metadata",)
277+
)
240278
cfg_dict: Dict[str, Any] = {
241279
"path": SUPER_AGENT_INVOKE_PATH,
242280
"headers": {},
@@ -250,9 +288,15 @@ def _build_protocol_configuration(
250288
name: str,
251289
business: Dict[str, Any],
252290
cfg: Optional[Config],
291+
prune_props: bool = False,
253292
) -> SuperAgentProtocolConfig:
254-
"""构造超级 Agent 的 ``protocolConfiguration`` Pydantic 模型."""
255-
config_json = _build_protocol_settings_config(name=name, business=business)
293+
"""构造超级 Agent 的 ``protocolConfiguration`` Pydantic 模型.
294+
295+
``prune_props`` 透传到 :func:`_build_protocol_settings_config`。
296+
"""
297+
config_json = _build_protocol_settings_config(
298+
name=name, business=business, prune_props=prune_props
299+
)
256300
settings: List[Dict[str, Any]] = [{
257301
"type": SUPER_AGENT_PROTOCOL_TYPE,
258302
"name": name,
@@ -292,7 +336,9 @@ def to_create_input(
292336
model_service_name=model_service_name,
293337
model_name=model_name,
294338
)
295-
pc = _build_protocol_configuration(name=name, business=business, cfg=cfg)
339+
pc = _build_protocol_configuration(
340+
name=name, business=business, cfg=cfg, prune_props=True
341+
)
296342
# SUPER_AGENT 是平台托管运行时, 不跑用户代码/容器, 但服务端仍要求
297343
# artifact_type / network_configuration 非空. 这里给占位默认值即可.
298344
return _SuperAgentCreateInput.model_construct(

agentrun/super_agent/api/data.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import httpx
2626

27+
from agentrun.super_agent.api.control import _prune_forwarded_props
2728
from agentrun.super_agent.model import InvokeResponseData
2829
from agentrun.super_agent.stream import parse_sse_async, SSEEvent
2930
from agentrun.utils.config import Config
@@ -67,11 +68,19 @@ def _build_invoke_body(
6768
# ``forwarded_extras`` 承载从 AgentRuntime 元数据读出的业务字段
6869
# (prompt/agents/tools/skills/sandboxes/workspaces/modelServiceName/modelName),
6970
# 由上层 ``SuperAgent.invoke_async`` 注入。``metadata`` 和 ``conversationId``
70-
# 由 SDK 管理, 不允许 extras 覆盖。
71+
# 由 SDK 管理, 不允许 extras 覆盖; 先 prune 掉 extras 里的 None scalar 和
72+
# 空 list (保留 SDK 即将覆写的 metadata 占位), 再把 SDK 托管字段写入。
7173
forwarded: Dict[str, Any] = dict(forwarded_extras or {})
74+
forwarded = _prune_forwarded_props(
75+
forwarded, keep_keys=("metadata", "conversationId")
76+
)
7277
forwarded["metadata"] = {"agentRuntimeName": self.agent_runtime_name}
7378
if conversation_id is not None:
7479
forwarded["conversationId"] = conversation_id
80+
else:
81+
# 即便用户 extras 里写了 conversationId (被 keep_keys 保留),
82+
# 外部 SDK 约定 conversation_id=None 时必须不出现。
83+
forwarded.pop("conversationId", None)
7584
return {"messages": list(messages), "forwardedProps": forwarded}
7685

7786
def _parse_invoke_response(

tests/unittests/super_agent/test_control.py

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,60 @@
2727
# 显式在模块加载时触发补丁 (幂等, 与 SuperAgentClient.__init__ 内触发点一致)。
2828
ensure_super_agent_patches_applied()
2929

30+
# ─── _prune_forwarded_props ───────────────────────────────────
31+
32+
33+
def test_prune_forwarded_props_removes_none_scalars():
34+
from agentrun.super_agent.api.control import _prune_forwarded_props
35+
36+
out = _prune_forwarded_props({"a": None, "b": "x"})
37+
assert out == {"b": "x"}
38+
39+
40+
def test_prune_forwarded_props_removes_empty_lists():
41+
from agentrun.super_agent.api.control import _prune_forwarded_props
42+
43+
out = _prune_forwarded_props({"a": [], "b": ["x"]})
44+
assert out == {"b": ["x"]}
45+
46+
47+
def test_prune_forwarded_props_keeps_keep_keys_even_when_none():
48+
from agentrun.super_agent.api.control import _prune_forwarded_props
49+
50+
out = _prune_forwarded_props(
51+
{"metadata": None, "other": None}, keep_keys=("metadata",)
52+
)
53+
assert out == {"metadata": None}
54+
55+
56+
def test_prune_forwarded_props_keeps_keep_keys_even_when_empty_list():
57+
from agentrun.super_agent.api.control import _prune_forwarded_props
58+
59+
out = _prune_forwarded_props(
60+
{"metadata": [], "other": []}, keep_keys=("metadata",)
61+
)
62+
assert out == {"metadata": []}
63+
64+
65+
def test_prune_forwarded_props_preserves_falsy_non_none_scalars():
66+
"""0, False, "" 不应被剔除 (只有 None 或空 list)."""
67+
from agentrun.super_agent.api.control import _prune_forwarded_props
68+
69+
out = _prune_forwarded_props({"n": 0, "b": False, "s": ""})
70+
assert out == {"n": 0, "b": False, "s": ""}
71+
72+
73+
def test_prune_forwarded_props_preserves_non_empty_lists_and_dicts():
74+
from agentrun.super_agent.api.control import _prune_forwarded_props
75+
76+
out = _prune_forwarded_props({
77+
"list": ["x"],
78+
"dict_empty": {}, # dict 不算 list, 保留
79+
"dict_full": {"k": "v"},
80+
})
81+
assert out == {"list": ["x"], "dict_empty": {}, "dict_full": {"k": "v"}}
82+
83+
3084
# ─── build_super_agent_endpoint ────────────────────────────────
3185

3286

@@ -116,11 +170,50 @@ def test_to_create_input_minimal():
116170
cfg_dict = json.loads(settings[0]["config"])
117171
assert cfg_dict["path"] == "/invoke"
118172
assert cfg_dict["headers"] == {}
173+
# 具体字段缺席断言放到 test_to_create_input_minimal_omits_unset_scalar_and_empty_list_fields
119174
forwarded = cfg_dict["body"]["forwardedProps"]
120-
assert forwarded["agents"] == []
121175
assert forwarded["metadata"] == {"agentRuntimeName": "alpha"}
122176

123177

178+
def test_to_create_input_minimal_omits_unset_scalar_and_empty_list_fields():
179+
"""create 时, 未设置的 scalar 字段和空 list 字段 MUST NOT 出现在 forwardedProps 里."""
180+
cfg = Config(account_id="123", region_id="cn-hangzhou")
181+
inp = to_create_input("alpha", cfg=cfg)
182+
cfg_dict = json.loads(
183+
inp.protocol_configuration.protocol_settings[0]["config"]
184+
)
185+
forwarded = cfg_dict["body"]["forwardedProps"]
186+
# metadata 永远保留
187+
assert forwarded["metadata"] == {"agentRuntimeName": "alpha"}
188+
# 未设置的 scalar 字段缺席
189+
assert "prompt" not in forwarded
190+
assert "modelServiceName" not in forwarded
191+
assert "modelName" not in forwarded
192+
# 空 list 字段缺席
193+
assert "agents" not in forwarded
194+
assert "tools" not in forwarded
195+
assert "skills" not in forwarded
196+
assert "sandboxes" not in forwarded
197+
assert "workspaces" not in forwarded
198+
199+
200+
def test_to_create_input_partial_only_keeps_set_fields():
201+
"""仅设置部分字段时, 未设置的字段不出现, 已设置的字段按原值出现."""
202+
cfg = Config(account_id="123", region_id="cn-hangzhou")
203+
inp = to_create_input(
204+
"bravo", prompt="hello", model_service_name="svc", cfg=cfg
205+
)
206+
cfg_dict = json.loads(
207+
inp.protocol_configuration.protocol_settings[0]["config"]
208+
)
209+
forwarded = cfg_dict["body"]["forwardedProps"]
210+
assert forwarded["prompt"] == "hello"
211+
assert forwarded["modelServiceName"] == "svc"
212+
assert "modelName" not in forwarded
213+
assert "agents" not in forwarded
214+
assert forwarded["metadata"] == {"agentRuntimeName": "bravo"}
215+
216+
124217
def test_to_create_input_full():
125218
cfg = Config(account_id="123", region_id="cn-hangzhou")
126219
inp = to_create_input(
@@ -382,6 +475,62 @@ def test_to_update_input_full_protocol_replace():
382475
assert forwarded["tools"] == ["t"]
383476

384477

478+
def test_to_update_input_keeps_null_for_none_scalars():
479+
"""update 路径: 合并后为 None 的 scalar 字段 MUST 仍写 null (不剪除).
480+
481+
保证 SDK 的 'update(model_name=None) 表示清空' 语义不被本次 PR 破坏。
482+
"""
483+
cfg = Config(account_id="123", region_id="cn-hangzhou")
484+
merged = {
485+
"prompt": None,
486+
"agents": [],
487+
"tools": [],
488+
"skills": [],
489+
"sandboxes": [],
490+
"workspaces": [],
491+
"model_service_name": None,
492+
"model_name": None,
493+
}
494+
inp = to_update_input("u1", merged, cfg=cfg)
495+
cfg_dict = json.loads(
496+
inp.protocol_configuration.protocol_settings[0]["config"]
497+
)
498+
forwarded = cfg_dict["body"]["forwardedProps"]
499+
# 明确包含 null (未被剪除)
500+
assert "prompt" in forwarded and forwarded["prompt"] is None
501+
assert (
502+
"modelServiceName" in forwarded
503+
and forwarded["modelServiceName"] is None
504+
)
505+
assert "modelName" in forwarded and forwarded["modelName"] is None
506+
# 空 list 也保留 (update 语义下 [] 代表 "清空列表", 不能剪除)
507+
assert forwarded["agents"] == []
508+
assert forwarded["tools"] == []
509+
510+
511+
def test_to_update_input_keeps_values_and_nulls_mixed():
512+
"""update: 有些字段有值, 有些是 None, 都应完整出现在 payload."""
513+
cfg = Config(account_id="123", region_id="cn-hangzhou")
514+
merged = {
515+
"prompt": "new",
516+
"agents": ["a"],
517+
"model_service_name": None,
518+
"model_name": "m",
519+
}
520+
inp = to_update_input("u2", merged, cfg=cfg)
521+
cfg_dict = json.loads(
522+
inp.protocol_configuration.protocol_settings[0]["config"]
523+
)
524+
forwarded = cfg_dict["body"]["forwardedProps"]
525+
assert forwarded["prompt"] == "new"
526+
assert forwarded["agents"] == ["a"]
527+
assert (
528+
"modelServiceName" in forwarded
529+
and forwarded["modelServiceName"] is None
530+
)
531+
assert forwarded["modelName"] == "m"
532+
533+
385534
# ─── Dara ListAgentRuntimesRequest systemTags 原生字段 ──────────────
386535
# ``systemTags`` 已由 Dara SDK 原生支持, 无需补丁。以下测试只校验 pydantic →
387536
# Dara roundtrip 能把 ``system_tags`` 保留到请求 query。

0 commit comments

Comments
 (0)