diff --git a/backend/packages/harness/deerflow/config/agents_config.py b/backend/packages/harness/deerflow/config/agents_config.py index f6f25da569..04b69c84ab 100644 --- a/backend/packages/harness/deerflow/config/agents_config.py +++ b/backend/packages/harness/deerflow/config/agents_config.py @@ -377,7 +377,11 @@ def load_agent_soul(agent_name: str | None, *, user_id: str | None = None) -> st soul_path = get_paths().base_dir / SOUL_FILENAME if not soul_path.exists(): return None - content = soul_path.read_text(encoding="utf-8").strip() + # ``utf-8-sig`` drops the BOM a Windows editor (Notepad, PowerShell 5.1) + # prepends. ``str.strip()`` does not remove U+FEFF, so reading this as + # plain ``utf-8`` would make the mark the first character of the text + # injected into the lead agent's system prompt. + content = soul_path.read_text(encoding="utf-8-sig").strip() return content or None from deerflow.persistence.agents import get_agent_store diff --git a/backend/packages/harness/deerflow/persistence/agents/file.py b/backend/packages/harness/deerflow/persistence/agents/file.py index 0d8e7283d8..123c37036d 100644 --- a/backend/packages/harness/deerflow/persistence/agents/file.py +++ b/backend/packages/harness/deerflow/persistence/agents/file.py @@ -83,7 +83,11 @@ class FileAgentStore(AgentStore): break if not soul_path.exists(): return None - content = soul_path.read_text(encoding="utf-8").strip() + # Match the default-agent read in :func:`deerflow.config.agents_config. + # load_agent_soul`: ``utf-8-sig`` drops the BOM a Windows editor writes, + # which ``str.strip()`` would otherwise leave as the leading character of + # the ```` block. + content = soul_path.read_text(encoding="utf-8-sig").strip() return content or None def list(self, *, user_id: str | None = None) -> list[AgentConfig]: diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py index c48478ab1a..fd63a0b5f6 100644 --- a/backend/tests/test_custom_agent.py +++ b/backend/tests/test_custom_agent.py @@ -323,6 +323,37 @@ class TestLoadAgentSoul: assert soul == expected_soul + def test_strips_a_leading_bom_from_named_agent_soul(self, tmp_path): + """A SOUL.md saved by a Windows editor keeps a UTF-8 BOM. + + ``str.strip()`` does not remove U+FEFF, so without normalization the mark + becomes the first character of the text injected into the lead agent's + ```` block. SOUL.md is edited outside DeerFlow (Notepad, PowerShell + 5.1), which is exactly where a BOM comes from. + """ + expected_soul = "You are a specialized code review expert." + _write_agent(tmp_path, "code-reviewer", {"name": "code-reviewer"}, soul="\ufeff" + expected_soul) + + with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from deerflow.config.agents_config import AgentConfig, load_agent_soul + + cfg = AgentConfig(name="code-reviewer") + soul = load_agent_soul(cfg.name) + + assert soul == expected_soul + + def test_strips_a_leading_bom_from_the_default_agent_soul(self, tmp_path): + """The default agent reads ``{base_dir}/SOUL.md`` directly — a second read site.""" + expected_soul = "You are the default agent." + (tmp_path / "SOUL.md").write_text("\ufeff" + expected_soul, encoding="utf-8") + + with patch("deerflow.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from deerflow.config.agents_config import load_agent_soul + + soul = load_agent_soul(None) + + assert soul == expected_soul + def test_missing_soul_file_returns_none(self, tmp_path): agent_dir = tmp_path / "agents" / "no-soul" agent_dir.mkdir(parents=True)