From 7df44f586cd5fbbb8200849cf480a047422b4515 Mon Sep 17 00:00:00 2001 From: Aari Date: Thu, 16 Jul 2026 14:44:22 +0800 Subject: [PATCH] fix(agents): refuse empty SOUL.md updates in update_agent (#4219) * fix(agents): refuse empty SOUL.md updates in update_agent setup_agent already rejects empty/whitespace soul (#3553). update_agent is the sibling write path and previously reported success while wiping a working SOUL.md. Mirror the same guard before staging. * fix(agents): guide the retry in the empty-SOUL update rejection Append "Omit the soul field if you do not want to change it." to the empty-soul error so the model self-corrects in one step instead of retrying with another null-like value, matching the "No fields provided" sibling message's helpfulness. Both regression tests assert the guidance. --- .../tools/builtins/update_agent_tool.py | 7 ++++ backend/tests/test_update_agent_tool.py | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/backend/packages/harness/deerflow/tools/builtins/update_agent_tool.py b/backend/packages/harness/deerflow/tools/builtins/update_agent_tool.py index e438579a9..1dbc74317 100644 --- a/backend/packages/harness/deerflow/tools/builtins/update_agent_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/update_agent_tool.py @@ -145,6 +145,13 @@ def update_agent( if soul is None and description is None and skills is None and tool_groups is None and model is None: return _err('No fields provided. Pass at least one of: soul, description, skills, tool_groups, model. Omit unchanged fields instead of passing null-like strings such as "null", "none", or "undefined".') + # Reject empty / whitespace-only soul before touching the filesystem. + # setup_agent already refuses this (#3553 / #3549); update_agent must too, + # otherwise a custom agent can report success while wiping a working + # SOUL.md and leaving the next turn with an empty personality. + if soul is not None and not soul.strip(): + return _err("soul content is empty; refusing to update agent with an empty SOUL.md. Omit the soul field if you do not want to change it.") + try: agent_name = validate_agent_name(agent_name_raw) except ValueError as e: diff --git a/backend/tests/test_update_agent_tool.py b/backend/tests/test_update_agent_tool.py index 594becfbd..593eb068c 100644 --- a/backend/tests/test_update_agent_tool.py +++ b/backend/tests/test_update_agent_tool.py @@ -262,6 +262,40 @@ def test_update_agent_updates_soul_only(tmp_path, patched_paths): assert "soul" in result.update["messages"][0].content +def test_update_agent_rejects_empty_soul_and_does_not_overwrite(tmp_path, patched_paths): + """Mirror setup_agent's empty-SOUL guard (#3553 / #3549). + + setup_agent refuses empty/whitespace soul before touching the filesystem. + update_agent previously accepted the same input and reported success while + writing a blank SOUL.md, wiping a working agent personality. + """ + agent_dir = _seed_agent(tmp_path, description="keep me", soul="original soul") + + result = update_agent.func(runtime=_runtime(), soul="") + + msg = result.update["messages"][0] + assert "soul content is empty" in msg.content + # Message must guide the retry (omit the field) so the model self-corrects + # in one step instead of retrying with another empty-ish value. + assert "Omit the soul field" in msg.content + assert msg.status == "error" + assert (agent_dir / "SOUL.md").read_text() == "original soul" + cfg = yaml.safe_load((agent_dir / "config.yaml").read_text()) + assert cfg["description"] == "keep me", "config must be untouched on empty-soul reject" + + +def test_update_agent_rejects_whitespace_only_soul_and_does_not_overwrite(tmp_path, patched_paths): + agent_dir = _seed_agent(tmp_path, description="keep me", soul="original soul") + + result = update_agent.func(runtime=_runtime(), soul=" \n\t ") + + msg = result.update["messages"][0] + assert "soul content is empty" in msg.content + assert "Omit the soul field" in msg.content + assert msg.status == "error" + assert (agent_dir / "SOUL.md").read_text() == "original soul" + + def test_update_agent_updates_description_only(tmp_path, patched_paths): agent_dir = _seed_agent(tmp_path, description="old desc", soul="keep this soul")