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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions agentrun/super_agent/__agent_async_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions agentrun/super_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion agentrun/super_agent/api/__data_async_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
56 changes: 51 additions & 5 deletions agentrun/super_agent/api/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"),
Expand All @@ -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": {},
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 10 additions & 1 deletion agentrun/super_agent/api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
151 changes: 150 additions & 1 deletion tests/unittests/super_agent/test_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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。
Expand Down
Loading
Loading