From 17b05718e029ca14b377c6f3bd0f73efca2f33bf Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Mon, 2 Nov 2026 00:34:59 +0900 Subject: [PATCH] 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 --- CHANGELOG.md | 8 +++++ CHANGELOG_zh.md | 8 ++++- backend/app/gateway/routers/agents.py | 6 ++-- .../harness/deerflow/config/AGENTS.md | 4 ++- .../deerflow/persistence/agents/base.py | 4 +++ backend/tests/test_agent_store_sql.py | 10 ++++++ backend/tests/test_custom_agent.py | 34 +++++++++++++++++++ 7 files changed, 70 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4713c0ce3c..e3b8160a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index da80474892..8f22cf3393 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -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 diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index e081139d6f..5fd5e84b7f 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -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 diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index 219cb9e5f1..ab72957d44 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -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`): diff --git a/backend/packages/harness/deerflow/persistence/agents/base.py b/backend/packages/harness/deerflow/persistence/agents/base.py index 99ba826b4c..e11d8b0cd2 100644 --- a/backend/packages/harness/deerflow/persistence/agents/base.py +++ b/backend/packages/harness/deerflow/persistence/agents/base.py @@ -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: diff --git a/backend/tests/test_agent_store_sql.py b/backend/tests/test_agent_store_sql.py index 309f73dff0..051e48c5de 100644 --- a/backend/tests/test_agent_store_sql.py +++ b/backend/tests/test_agent_store_sql.py @@ -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 diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py index eeba2e83bf..e4593271fc 100644 --- a/backend/tests/test_custom_agent.py +++ b/backend/tests/test_custom_agent.py @@ -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.