fix(agents): store an explicit null description as empty (#7638)

* fix(agents): store an explicit null description as empty

PUT /api/agents/{name} accepted "description": null and wrote it into the
agent's config, but AgentConfig.description is a str. Every later load of
that agent failed validation: the PUT itself, GET and repair PUTs returned
500, the file backend dropped the agent from the list, and the db backend
failed the owner's whole list.

Store an explicit null as the empty default, and have parse_agent_config
read a stored null description (already written by this route, or a bare
YAML key) as empty so affected agents load again.

* docs(changelog): reference #7638 for the agent null description fix

* docs(agents): document that a null description resets it to empty

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-11-02 00:34:59 +09:00 committed by GitHub
parent a0ce43d46a
commit 17b05718e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 70 additions and 4 deletions

View File

@ -19,6 +19,13 @@ This section accumulates work toward the **2.3.0** milestone
### Fixed
- **agents:** `PUT /api/agents/{name}` with `"description": null` now resets the
description to empty. It used to store the null, which the agent config
rejects, so the agent then failed every load: the update, GET and repair
updates returned 500, the file backend dropped it from the agent list, the
database backend failed the owner's whole list, and chats with it failed.
Agents already stored with a null description load again. ([#7638])
- **sandbox:** Cancelled native Runs now stop their owned AIO and POSIX local
bash processes, including ordinary `nohup` children, before releasing the
execution lease. Concurrent work and background services from successful
@ -15965,6 +15972,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7643]: https://github.com/bytedance/deer-flow/pull/7643
[#7651]: https://github.com/bytedance/deer-flow/pull/7651
[#7659]: https://github.com/bytedance/deer-flow/pull/7659
[#7638]: https://github.com/bytedance/deer-flow/pull/7638
[#7665]: https://github.com/bytedance/deer-flow/pull/7665
[#7723]: https://github.com/bytedance/deer-flow/pull/7723
[#7727]: https://github.com/bytedance/deer-flow/pull/7727

View File

@ -10,7 +10,7 @@
## [未发布]
本节累积面向 **2.3.0** 里程碑([2.3.0](https://github.com/bytedance/deer-flow/milestone/4))的工作。
截至 2026-10-28,该里程碑已合并 **1068 个拉取请求**。
### 新增
@ -20,6 +20,11 @@
### 修复
- **智能体:** `PUT /api/agents/{name}` 传入 `"description": null` 时现在会把描述重置为
空。此前会把 null 原样保存,而智能体配置不接受该值,导致之后每次加载都失败:更新本身、
GET 和修复性更新都返回 500,文件后端会把它从智能体列表中漏掉,数据库后端会让该用户的
整个列表失败,与它的对话也会失败。已以 null 描述保存的智能体现在可以重新加载。([#7638])
- **智能体:** 当模型提供方在不同轮次复用同一个工具调用 ID(例如每次响应都是 `call_0`)时,
澄清提问不再覆盖之前的问题。此前其消息 ID(同时也是人工输入的 `request_id`)只包含调用 ID,
后一个问题会原地替换前一个问题,后一次调用也因此缺少结果。现在 ID 还会包含发起该调用的
@ -12719,6 +12724,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7643]: https://github.com/bytedance/deer-flow/pull/7643
[#7651]: https://github.com/bytedance/deer-flow/pull/7651
[#7659]: https://github.com/bytedance/deer-flow/pull/7659
[#7638]: https://github.com/bytedance/deer-flow/pull/7638
[#7665]: https://github.com/bytedance/deer-flow/pull/7665
[#7723]: https://github.com/bytedance/deer-flow/pull/7723
[#7727]: https://github.com/bytedance/deer-flow/pull/7727

View File

@ -96,7 +96,7 @@ class AgentUpdateRequest(BaseModel):
"""Request body for updating a custom agent."""
display_name: AgentDisplayName | None = Field(default=None, description="Updated display name; null clears it")
description: str | None = Field(default=None, description="Updated description")
description: str | None = Field(default=None, description="Updated description; null resets it to empty")
model: str | None = Field(default=None, description="Updated model override")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
mcp_plugins: list[str] | None = Field(default=None, description="MCP installation selection (None=all, []=none)")
@ -601,7 +601,9 @@ async def update_agent(name: str, body: AgentUpdateRequest, request: Request) ->
if config_changed:
updated = {
"name": agent_cfg.name,
"description": body.description if "description" in fields_set else agent_cfg.description,
# Explicit null resets to the AgentConfig default; storing None
# would make every later load of this agent fail validation.
"description": (body.description or "") if "description" in fields_set else agent_cfg.description,
}
if "display_name" in fields_set:
updated["display_name"] = body.display_name or None

View File

@ -30,7 +30,9 @@ explicitly carries forward the existing owner's display name when re-bootstrappi
the Gateway explicitly overrides it when supplied, including null to clear.
Both stores use `parse_agent_config` to ignore only an invalid stored
`display_name` on read, logging the agent identifier without the invalid value
and without rewriting storage. Other config errors still
and without rewriting storage. It also reads a stored null `description`
(a bare YAML key, or what `PUT /api/agents/{name}` used to write) as `""`; that
route now stores an explicit null as `""`. Other config errors still
raise, and API create/update validation remains strict.
**Main Configuration** (`config.yaml`):

View File

@ -49,6 +49,10 @@ def parse_agent_config(data: dict[str, Any], name: str) -> AgentConfig:
data["name"] = name
known_fields = set(AgentConfig.model_fields.keys())
data = {k: v for k, v in data.items() if k in known_fields}
# A bare ``description:`` in YAML, or a null the agents API used to store,
# means "no description"; read it as the default instead of failing.
if "description" in data and data["description"] is None:
data["description"] = ""
try:
return AgentConfig(**data)
except ValidationError as exc:

View File

@ -54,6 +54,16 @@ def test_display_name_is_config_data_not_storage_identity(store):
assert store.get_soul("reviewer", user_id="u1") == "Soul"
def test_stored_null_description_reads_as_empty(store):
"""One null description must not 500 the owner's whole agent list."""
store.create("broken", {"name": "broken", "description": None}, "Soul", user_id="u1")
store.create("healthy", {"name": "healthy", "description": "fine"}, "Soul", user_id="u1")
assert store.get("broken", user_id="u1").description == ""
assert {cfg.name: cfg.description for cfg in store.list(user_id="u1")} == {"broken": "", "healthy": "fine"}
assert {cfg.name: cfg.description for _, cfg in store.list_all()} == {"broken": "", "healthy": "fine"}
def test_name_is_stored_lowercase_and_excluded_from_document(store):
store.create("Mixed", {"name": "Mixed", "description": "d"}, "s", user_id="u1")
# Stored lowercase (matches the on-disk layout), and the JSON document does

View File

@ -918,6 +918,40 @@ class TestAgentsAPI:
assert response.status_code == 200
assert response.json()["description"] == "new desc"
def test_update_agent_description_null_resets_to_empty(self, agent_client):
"""An explicit null description stores the default, not a null the loader rejects.
``AgentUpdateRequest.description`` accepts null but ``AgentConfig.description``
is a ``str``: the route used to persist ``description: null`` and then fail
to read it back, so the PUT, every later GET and repair PUT returned 500.
"""
agent_client.post("/api/agents", json={"name": "desc-agent", "description": "old desc", "soul": "p"})
response = agent_client.put("/api/agents/desc-agent", json={"description": None})
assert response.status_code == 200, response.text
assert response.json()["description"] == ""
tmp_path: Path = agent_client._tmp_path # type: ignore[attr-defined]
stored = yaml.safe_load((tmp_path / "users" / "test-user-autouse" / "agents" / "desc-agent" / "config.yaml").read_text(encoding="utf-8"))
assert stored["description"] == ""
assert agent_client.get("/api/agents/desc-agent").json()["description"] == ""
assert [agent["name"] for agent in agent_client.get("/api/agents").json()["agents"]] == ["desc-agent"]
def test_stored_null_description_loads_as_empty_and_can_be_repaired(self, agent_client):
"""A null already in storage (this bug, or a bare ``description:`` in YAML) reads as empty."""
from deerflow.persistence.agents.file import FileAgentStore
FileAgentStore().create("reviewer", {"description": None}, "Soul")
response = agent_client.get("/api/agents/reviewer")
assert response.status_code == 200, response.text
assert response.json()["description"] == ""
assert [agent["name"] for agent in agent_client.get("/api/agents").json()["agents"]] == ["reviewer"]
response = agent_client.put("/api/agents/reviewer", json={"description": "repaired"})
assert response.status_code == 200, response.text
assert response.json()["description"] == "repaired"
assert response.json()["soul"] == "Soul"
def test_update_agent_preserves_hand_authored_non_managed_fields(self, agent_client):
"""Hand-authored ``github:`` and ``memory_enabled`` fields must survive PATCH.